44 Commits

Author SHA1 Message Date
Rinjae 310919130c 게이트웨이 프록시 프로퍼티 true/false 전환
- djb.gateway.use-proxy / token-use-proxy 기본값 Y → true
- parseBool 헬퍼로 true/false 해석, 레거시 Y/N 자동 변환
- 기존 !"N" 비교가 값 "false"를 true로 오판하던 문제 수정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:58:54 +09:00
Rinjae 3c103ad3a4 API 그룹 팝업 JSP 추가 - Swagger Validator JS 등록 - Summernote CSS 추가
- API 그룹 팝업(djbApiGroupPopup.jsp) 화면 파일 추가
- Swagger Validator (djb-swagger-validator.js) 및 관련 리팩토링
- Summernote 콘텐츠 스타일 정의 (editor-content.css) 추가
2026-07-03 16:54:47 +09:00
Rinjae 5d70962521 - 2FA 수신자 결정적 해시 컬럼 추가 및 조회/중복 처리 개선
- Swagger UI i18n 한글 패치 파일 추가
- Swagger UI 응답/스니펫 패널 커스텀화 및 코드 생성 기능 추가
2026-07-02 18:16:57 +09:00
Rinjae e8d5c8ca03 swagger-ui.js.map 파일 업데이트:
- 최신 버전으로 맵 파일 교체
- Swagger UI 동작 관련 불일치 문제 수정
2026-07-02 18:16:38 +09:00
Rinjae c403118289 - StartupInfoPrinter 클래스 추가 - 서비스 시작 시 배너 출력
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- MessageCode 채널 필드 @Deprecated 추가 및 기본값 설정
- MessageCode 신규 생성자 추가 - channel 파라미터 제거
2026-07-01 10:37:08 +09:00
Rinjae a91b56ed11 - 법인 탈퇴 시 민감정보 삭제 로직 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSS 수정: 폰트 크기 제거 및 스타일 변경 적용
- 예상완료일 요일 계산 로직 추가
2026-06-30 15:38:40 +09:00
Rinjae 8d232734b1 IP 등록 UI 및 검증 로직 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `.ip-input-row`/`.btn-add-ip` 정렬 및 버튼 크기 수정
- Callback URL 관련 입력 및 검증 로직 주석 처리
- 화이트리스트 필수 입력 항목 검증 로직 추가
2026-06-30 10:54:22 +09:00
Rinjae 06c18e437b 회원가입 제출 필드 개선: 선택 입력 포함 모든 항목 전송 처리 2026-06-30 10:54:08 +09:00
Rinjae 97b9d2161a IP 입력 필드 스타일 개선: 정렬 및 버튼 크기 수정
- `.ip-input-row` 정렬 기준 추가 및 버튼 폭 고정
- `.btn-add-ip` 및 제거 버튼 스타일/반응형 조정
2026-06-30 10:54:00 +09:00
Rinjae 0c3b63a5b1 - UserSessionService 목 객체 추가 및 관련 의존성 주입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 시 휴대폰 번호 형식 통일 (`01012345678` → `010-1234-5678`)
- AccountControllerTest 테스트 코드 수정 및 누락 보완
2026-06-30 09:45:08 +09:00
Rinjae 582a3e0f69 회원가입 필수 항목 검증 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 입력/미충족 항목 구체적 수집 및 에러 메시지 개선
- 시나리오별/공통 항목 검증 로직 추가
2026-06-30 09:42:34 +09:00
Rinjae c1ae12e1ca 전화번호 정규화 유틸 추가 및 적용:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PhoneNumberUtil 도입: 한국번호 정규화/숫자 추출 지원
- 유틸 적용: 모든 저장 및 조회에 정규형 강제
- 초대 매칭 오류 수정: 휴대폰 형식 차이 대응
2026-06-29 18:26:15 +09:00
Rinjae 77aea79363 콘솔 로그 설정 추가 및 레벨 제어 기능 구현
- JVM 파라미터 기반 CONSOLE_LOG_ENABLED, LOG_LEVEL 제어 추가
- Stage 프로파일 정리 및 콘솔 로그 필터 설정 적용
2026-06-29 18:24:37 +09:00
Rinjae aa5e769148 JTA 설정 비활성화 및 신규 회원가입 경로 추가
- `spring.jta.enabled` 값 false로 변경 (stage/dev/prod 공통)
- 법인회원 초대 경로(`/signup/decision_process`) 신규 추가
2026-06-29 18:24:26 +09:00
Rinjae ee8e9e3529 CycloneDX BOM 플러그인 추가 - build.gradle에 org.cyclonedx.bom 적용 2026-06-29 18:24:07 +09:00
Rinjae 82948a13ce CSRF 만료 세션 처리 및 정적 리소스 캐싱 제어 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 만료 시 기존 세션 만료 안내로 리다이렉트 처리
- 정적 리소스 캐싱 토글 기능 추가 (개발 환경 즉시 반영 지원)
- 회원가입 페이지 및 세션 설정 스타일 수정
2026-06-26 15:05:19 +09:00
Rinjae f79e528fec 이메일 인증 재발송 기능 제거 및 관련 코드 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `emailResendPopup` 및 팝업 호출 로직 삭제
- `InquiryAdminNotifier` → `CommunityAdminNotifier`로 클래스 명칭 변경
- 제휴 신청 시 관리자 알림 로직 추가
2026-06-24 19:45:37 +09:00
Rinjae 6f776ed72a 로그아웃 핸들러 추가: 세션 무효화 전 DB 정리 및 로깅 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-24 16:52:09 +09:00
Rinjae e242d3ed06 - 모바일 약관 탭 UI 개선 - 스크롤 추가 및 스타일 조정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 필수 입력 경고 메세지 추가 및 인증 미완료 경고 추가
2026-06-24 15:32:18 +09:00
Rinjae d7abe2c2e5 - 테스트 데이터에 휴대폰 번호 추가 및 관련 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 조건: 이메일 → 휴대폰 번호로 변경
2026-06-23 21:06:28 +09:00
Rinjae 84d92fced9 - 초대 로직 변경: 이메일→휴대폰 기반 초대 전환 및 UI 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 알림 수신 동의 추가: 초대/취소 시 알림 발송 옵션 탑재
- 관리자 기능 추가: 소속 제외 및 권한 조정 기능 구현
2026-06-23 20:58:27 +09:00
Rinjae 53468c3b83 WAR 파일 해시 파일 확장자 변경 및 MD5SUM 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-23 13:58:02 +09:00
Rinjae 198f6fc82a - 회원가입 이메일 인증 페이지 추가 및 이메일 인증 흐름 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 휴대폰 번호 중복 검증 로직 추가 및 프로퍼티 기반 설정 활성화
- 알림 수신 동의 항목 및 뷰 로직 추가
2026-06-22 19:20:03 +09:00
Rinjae 21c500610c Damo 적용 준비 코드
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- KjbNotRnnoJpaAttributeConverter 삭제 및 PersonalDataEncryptConverter 추가
- damo-manager 지원을 위한 JSP 추가 및 레거시 암호화 마이그레이션 컨트롤러 구현
- 기존 엔티티 암호화 로직 변경 및 build.gradle에 damo-manager 의존성 추가
2026-06-22 16:29:45 +09:00
Rinjae 687d4798d4 - error 페이지 개발 환경용 상세 오류 정보 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 스택 트레이스 및 예외 클래스 노출 로직 구현
- activeProfile 표시 및 스타일 업데이트
2026-06-22 15:39:59 +09:00
Rinjae 5adfd42751 - CSRF 토큰 저장소 쿠키 → 세션 전환 및 클라이언트 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- error 페이지 텍스트 동적 처리 추가 (prod 환경 구분)
- CSRF 메타 데이터 추가 및 JS/AJAX CSRF 로직 일원화
2026-06-22 14:19:32 +09:00
Rinjae 6694715195 Merge branch 'feature/session-timeout'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-06-19 18:09:47 +09:00
Rinjae fc96c13df2 - 로그인 중복 세션 확인 및 기존 세션 강제 로그아웃 처리 추가
- 세션 타이머 데스크톱·모바일 표시 및 경고 스타일 동기화
2026-06-19 18:06:40 +09:00
Rinjae 7265ad6589 - 세션 타이머·유휴 자동 로그아웃 기능 추가
- 만료 직전 연장 확인 모달, PortalProperty로 시간 설정
- PTL_USER_SESSION 기반 세션 검증 필터/API 도입
- 로그인 시 중복 세션 강제 로그아웃 처리
- 세션 테이블 DDL 및 로그인 안내 메시지 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:45:37 +09:00
Rinjae 02f9d41334 OAuth2 개발가이드 추가 및 테이블 UI 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OAuth2 개발가이드 링크 추가
- 테이블 컬럼 스타일 조정: 모바일 대응 및 오버플로 방지
- Jackson 직렬화 대응 날짜 포맷 수정
2026-06-19 17:22:33 +09:00
Rinjae fe4b176512 CSS source map 경로 수정 및 매핑 데이터 규칙 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-19 10:27:17 +09:00
Rinjae 17c27e4398 문의 상태값 수정: IN_PROGRESS → RESPONDED
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 문의 상태 필드 및 관련 로직/뷰 업데이트
- Validation 패턴 및 테스트 코드 반영
- 뷰 상태 배지 및 응답 섹션 표시 로직 수정
2026-06-19 10:16:16 +09:00
Rinjae 10ff5d6f2f 마스킹 표준화 적용 (eapim-admin/portal/JS)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin: elink-online-core 표준 위임(e메일/이름/전화/IP)으로 규칙 일원화
- portal/JS: 표준 로직 직접 구현 + 기존 owner 비마스킹 유지
- 호출처 무수정/인라인 중복 제거/테스트 통과
2026-06-18 17:56:32 +09:00
Rinjae-gf63 f671f491b7 - PortalUser-테이블명 대소문자 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Credential-EqualsAndHashCode 어노테이션 추가
- 사업제휴신청 메시지 수정 및 파일 null 검사 추가
2026-06-18 16:28:22 +09:00
Rinjae-gf63 3d68589ae7 - 불필요 로그 설정 및 springProfile(gf63) 제거
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- "사업 제휴 문의" 텍스트를 "피드백/개선요청"으로 변경
- 과금 관리 관련 링크 제거
2026-06-18 16:03:03 +09:00
rinjae 7268aef211 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:51:14 +09:00
rinjae 85b2f01ce0 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:43:24 +09:00
rinjae 0f255d7337 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:17:31 +09:00
rinjae a8323e78f5 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:11:00 +09:00
Rinjae fa8fce4f2d Jenkinsfile 추가 및 CI/CD 스크립트 통합
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 기존 *.sh 스크립트 제거 및 Jenkinsfile.deploy로 통합
- 테스트 전용 Jenkinsfile.test-build 추가
2026-06-18 11:35:12 +09:00
Rinjae 4e7044b9fe 폰트 패밀리 정의 분리 및 웹폰트 리소스 경로 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OneShinhan, Spoqa Han Sans Neo 등 역할별 폰트 패밀리 분리
- 제목/본문 영역에 맞는 폰트 설정 및 재정의
- 웹폰트 리소스 URL 경로 및 성능 최적화
2026-06-17 20:47:01 +09:00
Rinjae bddd7e3f85 공지사항/Q&A 개선 - 장애 점검 정보 노출 및 상단고정 정렬 - Q&A 이벤트 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-16 14:54:02 +09:00
Rinjae 71f4cb6f79 Q&A 이벤트 리팩토링
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryCommentCreatedAdminEvent 삭제 및 경로 변경
- userName 정보 추가 및 description 업데이트
- inquirySubject 처리 로직 개선
2026-06-15 18:57:51 +09:00
Rinjae 171feee9ea Q&A 댓글 관련 코드 리팩토링 및 역할 기반 알림 기능 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryComment 코드 구조 리팩토링 및 역할 분리
- portal-admin 알림 기능 추가 및 예외 처리 개선
- InquiryComment 관련 불필요 클래스/메서드 제거
2026-06-15 18:49:14 +09:00
204 changed files with 7525 additions and 10453 deletions
+220
View File
@@ -0,0 +1,220 @@
pipeline {
agent { label 'djb-vm' }
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
JAVA_HOME = '/apps/opts/jdk8'
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
NODE_HOME = '/apps/opts/node-v24'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
CATALINA_BASE = '/prod/eapim/devportal'
CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
CATALINA_PID = '/prod/eapim/devportal/temp/catalina.pid'
DEPLOY_HTTP_PORT = '39130'
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
java -version
gradle --version
'''
}
}
stage('Test') {
steps {
sh 'gradle clean test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
}
}
}
stage('Build WAR') {
steps {
sh 'gradle build -x test --no-daemon'
}
post {
success {
sh '''
set -eu
cd build/libs
for f in eapim-portal.war eapim-portal-boot.war; do
sha1sum "$f" > "$f.sha1"
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
}
}
}
stage('Stop Tomcat') {
steps {
sh '''
set +e
systemctl --user stop eapim-portal 2>/dev/null
if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then
JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true
for i in $(seq 1 30); do
[ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break
sleep 1
done
fi
rm -f "$CATALINA_PID"
'''
}
}
stage('Deploy ROOT.war') {
steps {
sh '''
set -eu
DEPLOY_DIR="$CATALINA_BASE/webapps"
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
cp build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
ls -la "$DEPLOY_DIR"
'''
}
}
stage('Start Tomcat and readiness') {
steps {
sh '''
set -eu
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
BEFORE=0
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
systemctl --user start eapim-portal
DEADLINE=$(($(date +%s) + 120))
LIVE_OK=0
STATUS=000
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
if [ "$LIVE_OK" = "0" ]; then
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
"http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
if [ "$LIVE" = "200" ]; then
echo "Liveness OK (/health)"
LIVE_OK=1
fi
fi
if [ "$LIVE_OK" = "1" ]; then
STATUS=$(curl -sS -o /tmp/eapim-ready-body.json -w '%{http_code}' --max-time 3 \
"http://localhost:$DEPLOY_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
if [ "$STATUS" = "200" ]; then
echo "Readiness OK (/health/ready)"
cat /tmp/eapim-ready-body.json
echo
break
fi
fi
if [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | grep -qE "Application listener .* Exception|SEVERE.*startup.Catalina"; then
echo "Startup error detected in log"
tail -c +$((BEFORE+1)) "$LOG" | tail -80
exit 1
fi
sleep 2
done
if [ "$STATUS" != "200" ]; then
echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
[ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
exit 1
fi
echo "--- key boot log lines ---"
tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
'''
}
}
}
post {
success {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS","attachments":[{"color":"good","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"SUCCESS","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
'''
}
failure {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) FAILURE","attachments":[{"color":"danger","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"FAILURE","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
'''
}
}
}
+105
View File
@@ -0,0 +1,105 @@
pipeline {
agent { label 'djb-vm' }
triggers {
pollSCM('H/10 * * * *')
}
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
JAVA_HOME = '/apps/opts/jdk8'
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
NODE_HOME = '/apps/opts/node-v24'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
java -version
gradle --version
node --version || true
npm --version || true
'''
}
}
stage('Test') {
steps {
sh 'gradle clean test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
}
}
}
stage('Build WAR') {
steps {
sh 'gradle build -x test --no-daemon'
}
post {
success {
sh '''
set -eu
cd build/libs
for f in eapim-portal.war eapim-portal-boot.war; do
sha1sum "$f" > "$f.sha1"
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
}
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
echo "========================"
java -version
javac -version
echo "========================"
gradle --version
echo "========================"
gradle clean build -x test --no-daemon
+13 -9
View File
@@ -3,6 +3,7 @@ plugins {
id 'war'
id 'eclipse'
id 'idea'
id 'org.cyclonedx.bom' version '3.2.4'
id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.1.3'
}
@@ -29,9 +30,12 @@ dependencies {
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
// 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
implementation project(':elink-online-core-jpa')
implementation project(':elink-portal-common')
@@ -135,7 +139,6 @@ sourceSets {
main {
java {
srcDir 'src/main/java'
srcDir 'src/main/java/djb'
}
}
}
@@ -151,11 +154,11 @@ compileJava {
}
processResources {
exclude { details ->
details.file.name.startsWith('application-') &&
details.file.name.endsWith('.yml') &&
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
}
// exclude { details ->
// details.file.name.startsWith('application-') &&
// details.file.name.endsWith('.yml') &&
// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
// }
}
test {
@@ -171,6 +174,7 @@ test {
bootWar {
archiveFileName = "eapim-portal-boot.war"
mainClass = 'com.eactive.apim.portal.PortalApplication'
}
war {
-3
View File
@@ -1,3 +0,0 @@
./gradlew bootWar
docker build -t eactive-portal .
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
# Check if the war file is provided
if [ -z "$1" ]; then
echo "Usage: $0 /path/to/DEVPortal.war"
exit 1
fi
# Variables
WAR_FILE="$1"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEST_DIR="/app/apim/deploy/DVPWeb"
BACKUP_DIR="/app/apim/backup"
# Create backup directory if not exists
mkdir -p $BACKUP_DIR
# Backup the existing directory
if [ -d "$DEST_DIR" ]; then
echo "Backing up existing directory..."
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
fi
# Create destination directory if not exists
mkdir -p $DEST_DIR
# Unzip the file to the dedicated location
echo "Extracting WAR file to $DEST_DIR..."
unzip $WAR_FILE -d $DEST_DIR
echo "deploy successful"
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Check if the war file is provided
if [ -z "$1" ]; then
echo "Usage: $0 /path/to/DEVPortal.war"
exit 1
fi
# Variables
WAR_FILE="$1"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEST_DIR="/app/apim/deploy/DVPWeb"
STATIC_DIR="/app/apim/deploy/static"
BACKUP_DIR="/app/apim/backup"
# Create backup directory if not exists
mkdir -p $BACKUP_DIR
# Backup the existing directory
if [ -d "$STATIC_DIR" ]; then
echo "Backing up existing static directory..."
mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
fi
if [ -d "$DEST_DIR" ]; then
echo "Backing up existing directory..."
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
fi
# Create destination directory if not exists
mkdir -p $DEST_DIR
mkdir -p $STATIC_DIR
# unzip the file to the dedicated location
echo "Extracting WAR file to $DEST_DIR..."
unzip $WAR_FILE -d $DEST_DIR
mv $DEST_DIR/static/* $STATIC_DIR/
echo "deploy successful"
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
# gradle 명령을 대체합니다.
command gradle $@
Vendored Regular → Executable
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,36 @@
-- =============================================================================
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
-- =============================================================================
CREATE TABLE PTL_USER_SESSION
(
SESSION_ID VARCHAR2(128) NOT NULL,
USER_ID VARCHAR2(36) NOT NULL,
LOGIN_ID VARCHAR2(200) NOT NULL,
LOGIN_TIME TIMESTAMP(6) NOT NULL,
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
IP_ADDRESS VARCHAR2(45),
USER_AGENT VARCHAR2(500),
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
);
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
-- COMMIT;
@@ -0,0 +1,23 @@
-- =============================================================================
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
--
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
--
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
-- =============================================================================
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
COMMIT;
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping("/agreements")
public class AgreementsController {
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
public static final String TERMS_AGREEMENTS = "fragment/djbank/terms_agreements";
/** 개인정보처리방침은 약관 페이지에서 분리되어 제주은행 공식 사이트 외부 링크로 대체됨 */
public static final String PRIVACY_POLICY_EXTERNAL_URL =
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
private final AgreementsFacade agreementsFacade;
@@ -28,28 +32,49 @@ public class AgreementsController {
public String showTerms(@RequestParam(required = false) String tab,
@RequestParam(required = false) String publishedOn,
Model model) {
String currentTab = tab != null ? tab : "terms";
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
if ("privacy".equals(currentTab)) {
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
}
AgreementType type;
if ("privacy".equals(tab)) {
type = AgreementType.PRIVACY_POLICY;
} else {
type = AgreementType.TERMS_OF_USE;
switch (currentTab) {
case "privacy-collect":
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
type = AgreementType.PRIVACY_COLLECT;
break;
case "notification":
type = AgreementType.NOTIFICATION_CONSENT;
break;
case "terms":
default:
currentTab = "terms";
type = AgreementType.TERMS_OF_USE;
break;
}
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
model.addAttribute("agreementsList", agreementsList);
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
AgreementsDTO selectedAgreement = null;
if (agreementsList != null && !agreementsList.isEmpty()) {
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
}
model.addAttribute("selectedAgreement", selectedAgreement);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
model.addAttribute("agreementTitle", type.getDescription());
model.addAttribute("agreementType", type.getCode());
model.addAttribute("currentTab", tab != null ? tab : "terms");
model.addAttribute("currentTab", currentTab);
return TERMS_AGREEMENTS;
}
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
}
// 2. 개인정보수집동의서 처리
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
if (privacyCollectType != null
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
Optional<Agreements> privacyCollect = agreementsService
.findLastesBeforeDate(
privacyCollectType,
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
defaultAgreement.setName("개인정보처리방침");
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT":
defaultAgreement.setName("개인정보수집동의서");
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT_IND":
defaultAgreement.setName("개인용 개인정보수집동의서");
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/apis")
@RequiredArgsConstructor
@Slf4j
public class TestbedSpecController {
@Autowired
private ApiSpecInfoService apiSpecInfoService;
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedSpecServerRewriter serverRewriter;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> getSwagger(@PathVariable String id) {
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
try {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
return ResponseEntity.ok().body(content);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
} catch (IOException e) {
log.error("Failed to read default token api spec file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
return null;
}
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent()) {
StringUtils.hasText(spec.get().getTestbedSpec());
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -20,6 +21,20 @@ public class APISender {
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
private final DjbTestbedGatewayProperty gatewayProperty;
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
this.gatewayProperty = gatewayProperty;
}
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
private void applyTimeout(HttpURLConnection connection) {
int t = gatewayProperty.timeoutMillis();
connection.setConnectTimeout(t);
connection.setReadTimeout(t);
}
public String requestPost(String uri, String requestBody) throws IOException {
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
@@ -58,6 +73,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -82,6 +98,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -125,10 +142,11 @@ public class APISender {
return response.toString();
}
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
URL endpoint = new URL(uri);
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
@@ -4,6 +4,8 @@ package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.io.BufferedReader;
import java.io.IOException;
import java.net.URI;
@@ -20,6 +22,7 @@ import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -65,7 +68,23 @@ public class ApiTesterFilter implements Filter {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
if (url.contains("/api/v1/oauth/token")){
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return;
}
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
// 반환하기 위해 try 로 감싼다.
try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
if (tokenRequest) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
@@ -74,82 +93,174 @@ public class ApiTesterFilter implements Filter {
}
String body = sb.toString();
// Parse request parameters
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
String[] keyValue = pair.split("=");
if (keyValue.length == 2) {
params.put(keyValue[0], keyValue[1]);
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
String[] keyValue = pair.split("=");
if (keyValue.length == 2) {
params.put(keyValue[0], keyValue[1]);
}
}
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
"}";
response.setContentType("application/json");
response.getWriter().println(token);
} else {
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json");
response.getWriter().println(tokenResponse);
}
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
"}";
response.setContentType("application/json");
response.getWriter().println(token);
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return;
}
String responseType = apiSpecInfoDto.getResponseType();
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return;
}
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType);
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
}
headers.remove("original-url");
headers.remove("original-api-id");
String targetUri;
Map<String, String[]> paramMap;
if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host");
headers.remove("Host");
targetUri = url;
paramMap = new HashMap<>();
} else {
String mockUrl = apiSpecInfoDto.getMockUrl();
String method = apiSpecInfoDto.getApiMethod();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
targetUri = apiSpecInfoDto.getMockUrl();
paramMap = extractQueryParams(url);
}
Map<String, String> headers = new HashMap<>();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
// 연결 실패 등 네트워크 오류
logger.error("테스트베드 프록시 호출 실패", e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
}
}
/** 요청 본문 전체를 문자열로 읽는다. */
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
private Map<String, String[]> extractQueryParams(String originalUrl) {
Map<String, String[]> paramMap = new HashMap<>();
String[] parts = originalUrl.split("\\?");
if (parts.length > 1) {
for (String param : parts[1].split("&")) {
String[] kv = param.split("=");
if (kv.length > 1) {
paramMap.put(kv[0], new String[]{kv[1]});
}
Map<String, String[]> paramMap = new HashMap<>();
String originalUrl = headers.get("original-url");
//sprlit original url with ? get the second part then split with & put into paramMap
String[] originalUrlArr = originalUrl.split("\\?");
if (originalUrlArr.length > 1) {
String[] paramArr = originalUrlArr[1].split("&");
for (String param : paramArr) {
String[] paramKeyValue = param.split("=");
if (paramKeyValue.length > 1) {
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
}
}
}
headers.remove("original-url");
headers.remove("original-api-id");
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr = "";
if (method.equalsIgnoreCase("post")) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
} else {
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
}
return paramMap;
}
/** 상태코드 + JSON 본문 응답. */
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
response.getWriter().println(json);
}
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
private String escapeJson(String s) {
if (s == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\\': sb.append("\\\\"); break;
case '"': sb.append("\\\""); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
return sb.toString();
}
@Override
@@ -31,4 +31,21 @@ public class ApprovalDTO {
private LocalDateTime createdDate;
private LocalDateTime approvalDate;
private String expectEndDate; //예상완료일 (yyyyMMdd)
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
public String getExpectEndDateDayOfWeek() {
if (expectEndDate == null || expectEndDate.length() < 8) {
return "";
}
try {
return java.time.LocalDate
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
.getDayOfWeek()
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
} catch (Exception e) {
return "";
}
}
}
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.LocalDateTime;
import java.util.Optional;
@Component
public class AuthNumberStorage {
private static final String HASH_ALGORITHM = "HmacSHA256";
private final TwoFactorAuthRepository twoFactorAuthRepository;
/**
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
*/
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String hashPepper;
@Autowired
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
this.twoFactorAuthRepository = twoFactorAuthRepository;
}
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
String lookupKey = hashRecipient(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
auth.setRecipientKey(lookupKey);
twoFactorAuthRepository.save(auth);
}
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
return twoFactorAuthRepository.findByRecipient(recipientKey);
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
}
public void deleteAuthNumber(String recipientKey) {
twoFactorAuthRepository.deleteById(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
}
}
/**
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
*/
private String hashRecipient(String recipient) {
if (recipient == null) {
return null;
}
try {
Mac mac = Mac.getInstance(HASH_ALGORITHM);
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
}
}
}
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IncidentAffectedApiDTO {
private String apiId;
private String apiName;
}
@@ -8,11 +8,18 @@ import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PortalNoticeDTO {
public static final String NOTICE_TYPE_NORMAL = "1";
public static final String NOTICE_TYPE_MAINTENANCE = "2";
public static final String NOTICE_TYPE_INCIDENT = "3";
private String id;
@NotEmpty(message = "제목을 입력해주세요.")
@@ -29,9 +36,33 @@ public class PortalNoticeDTO {
private boolean useYn;
private String fixYn;
private String noticeType;
private String inquirerName;
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
// ───── 장애/점검 연동 필드 ─────
private String summary;
private LocalDateTime startedAt;
private LocalDateTime endAt;
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
}
public boolean isMaintenanceType() {
return NOTICE_TYPE_MAINTENANCE.equals(noticeType);
}
public boolean isIncidentOrMaintenance() {
return isIncidentType() || isMaintenanceType();
}
}
@@ -1,8 +1,12 @@
package com.eactive.apim.portal.apps.community.notice.service;
import com.eactive.apim.portal.apps.community.notice.dto.IncidentAffectedApiDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
@@ -12,7 +16,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service("portalNoticeFacade")
@RequiredArgsConstructor
@@ -20,12 +27,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeService portalNoticeService;
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
PortalNoticeSearch search = new PortalNoticeSearch();
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
// Pageable pageable = Pageable.ofSize(3);
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
return notices.getContent();
}
@@ -36,13 +44,47 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, pageable);
// 상단고정(fixYn='Y') 우선 정렬 — 호출자의 sort 앞에 결합 (admin Service 와 일관)
Sort fixYnFirst = Sort.by(Sort.Direction.DESC, "fixYn");
Pageable fixedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
fixYnFirst.and(pageable.getSort())
);
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, fixedPageable);
return noticesPage.map(portalNoticeMapper::map);
}
@Override
public PortalNoticeDTO getNotice(String id) {
PortalNotice portalNotice = portalNoticeService.getNotice(id);
return portalNoticeMapper.map(portalNotice);
PortalNoticeDTO dto = portalNoticeMapper.map(portalNotice);
populateIncident(dto);
return dto;
}
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
dto.setAffectedApis(apis);
}
}
@@ -54,7 +54,7 @@ public class PartnershipApplicationController {
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
// 성공 메시지 추가
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
httpServletRequest.getSession().removeAttribute("previousPage");
return "redirect:/partnership";
@@ -5,14 +5,18 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -21,13 +25,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationService partnershipApplicationService;
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
private final CommunityAdminNotifier portalAdminNotifier;
@Override
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
FileInfo file = null;
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
@@ -43,5 +49,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
partnershipApplication.setFileId(file.getFileId());
}
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
// 개선요청/제휴 게시물 등록 시 portal-admin 에게 알림 (INQUIRY_CREATED 와 동일 패턴)
PortalAuthenticatedUser writer = SecurityUtil.getPortalAuthenticatedUser();
Map<String, Object> params = new HashMap<>();
params.put("partnershipId", partnershipApplication.getId());
params.put("bizSubject", partnershipApplication.getBizSubject());
if (writer != null) {
params.put("writerName", writer.getUserName());
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
}
@@ -25,7 +25,7 @@ public class InquiryDTO {
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
private String inquiryDetail;
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
private String inquiryStatus;
private String inquirerName;
@@ -6,8 +6,10 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -15,6 +17,8 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -22,6 +26,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final CommunityAdminNotifier inquiryAdminNotifier;
@Override
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
@@ -81,9 +86,16 @@ public class InquiryFacadeImpl implements InquiryFacade {
@Override
public void createInquiry(InquiryDTO inquiryDTO) throws IOException {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
Inquiry inquiry = inquiryMapper.toEntity(inquiryDTO);
inquiry.setInquirer(SecurityUtil.getPortalAuthenticatedUser());
inquiry.setInquirer(current);
inquiryService.createInquiry(inquiry);
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
@@ -152,25 +152,6 @@ public class AccountRecoveryController {
}
}
@PostMapping("/resend_verification_email")
public ResponseEntity<Map<String, String>> resendVerificationEmail(@RequestParam String loginId) {
Map<String, String> response = new HashMap<>();
try {
portalUserAuthService.resendVerificationEmail(loginId);
response.put("success", "인증 이메일이 재발송되었습니다.");
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
} catch (Exception e) {
response.put("error", "이메일 발송 중 오류가 발생했습니다.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@GetMapping("/dormant_account")
public String showDormantAccountPage(HttpSession session,Model model) {
@@ -0,0 +1,130 @@
package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
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.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.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/session")
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("valid", false);
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
if (forceLoggedOut) {
result.put("valid", false);
result.put("remainingSeconds", 0);
} else {
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("valid", remaining > 0);
result.put("remainingSeconds", remaining);
}
return ResponseEntity.ok(result);
}
/**
* 하트비트 - lastAccessTime 갱신 (세션 연장)
*/
@PostMapping("/heartbeat")
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
userSessionService.updateLastAccessTime(sessionId);
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("remainingSeconds", remaining);
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.session.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
*
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "PTL_USER_SESSION")
public class UserSession {
@Id
@Column(name = "SESSION_ID", length = 128, nullable = false)
private String sessionId;
@Column(name = "USER_ID", length = 36, nullable = false)
private String userId;
@Column(name = "LOGIN_ID", length = 200, nullable = false)
private String loginId;
@Column(name = "LOGIN_TIME", nullable = false)
private LocalDateTime loginTime;
@Column(name = "LAST_ACCESS_TIME", nullable = false)
private LocalDateTime lastAccessTime;
@Column(name = "IP_ADDRESS", length = 45)
private String ipAddress;
@Column(name = "USER_AGENT", length = 500)
private String userAgent;
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
@Builder.Default
private String forceLogout = "N";
}
@@ -0,0 +1,124 @@
package com.eactive.apim.portal.apps.session.filter;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
*
* <ol>
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionValidationFilter extends OncePerRequestFilter {
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
"/api/session/",
"/login",
"/actionLogin.do",
"/actionLogout.do",
"/css/",
"/js/",
"/img/",
"/plugins/",
"/fonts/",
"/favicon.ico",
"/error",
"/health"
);
private final UserSessionService userSessionService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty()) {
path = path.substring(contextPath.length());
}
// 제외 경로 체크
if (isExcludedPath(path)) {
filterChain.doFilter(request, response);
return;
}
// 인증되지 않은 요청은 통과
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
filterChain.doFilter(request, response);
return;
}
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
filterChain.doFilter(request, response);
return;
}
String sessionId = httpSession.getId();
// 1. 강제 로그아웃 체크 (중복 로그인)
if (userSessionService.isForceLoggedOut(sessionId)) {
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?forceLogout=true");
return;
}
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
long remaining = userSessionService.getRemainingSeconds(sessionId);
if (remaining <= 0) {
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?expired=true");
return;
}
// 3. lastAccessTime 갱신 (60초 스로틀링)
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
long now = System.currentTimeMillis();
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
userSessionService.updateLastAccessTime(sessionId);
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
}
filterChain.doFilter(request, response);
}
private boolean isExcludedPath(String path) {
for (String prefix : EXCLUDED_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,33 @@
package com.eactive.apim.portal.apps.session.repository;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
List<UserSession> findByLoginId(String loginId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
int forceLogoutAllSessions(@Param("loginId") String loginId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@Modifying
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
}
@@ -0,0 +1,173 @@
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.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
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";
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
private static final long CLEANUP_MARGIN_MINUTES = 1;
private final UserSessionRepository userSessionRepository;
private final PortalPropertyService portalPropertyService;
/**
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
*/
@Transactional(readOnly = true)
public Optional<UserSession> getActiveSession(String loginId) {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
return sessions.stream()
.filter(s -> "N".equals(s.getForceLogout()))
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
.findFirst();
}
/**
* 새 세션 등록
*/
@Transactional
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
UserSession session = UserSession.builder()
.sessionId(sessionId)
.userId(userId)
.loginId(loginId)
.loginTime(LocalDateTime.now())
.lastAccessTime(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(truncate(userAgent, 500))
.forceLogout("N")
.build();
userSessionRepository.save(session);
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
}
/**
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
*/
@Transactional
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
if (count > 0) {
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
*/
@Transactional
public void forceLogoutAllSessions(String loginId) {
int count = userSessionRepository.forceLogoutAllSessions(loginId);
if (count > 0) {
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@Transactional(readOnly = true)
public boolean isForceLoggedOut(String sessionId) {
return userSessionRepository.findById(sessionId)
.map(s -> "Y".equals(s.getForceLogout()))
.orElse(false);
}
/**
* 세션 삭제
*/
@Transactional
public void removeSession(String sessionId) {
if (userSessionRepository.existsById(sessionId)) {
userSessionRepository.deleteById(sessionId);
log.debug("세션 삭제 - sessionId: {}", sessionId);
}
}
/**
* 마지막 접근 시간 갱신 (세션 연장)
*/
@Transactional
public void updateLastAccessTime(String sessionId) {
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
*/
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);
}
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@Transactional(readOnly = true)
public long getRemainingSeconds(String sessionId) {
int timeoutMinutes = getSessionTimeoutMinutes();
return userSessionRepository.findById(sessionId)
.map(s -> {
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
return Math.max(0, remaining);
})
.orElse(0L);
}
/**
* 만료 세션 정리 (1분 주기).
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
*/
@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpiredSessions() {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
if (deleted > 0) {
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
}
}
private String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}
@@ -1,28 +1,18 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
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;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
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.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@@ -53,6 +48,7 @@ public class AccountController {
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
@PostMapping("/confirm_password")
@@ -105,6 +101,12 @@ public class AccountController {
session.removeAttribute("success");
session.removeAttribute("redirectUrl");
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
@@ -142,8 +144,8 @@ public class AccountController {
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getEmailAddr(), InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
@@ -262,7 +264,7 @@ public class AccountController {
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("registrationType", "corporate");
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
return "apps/mypage/orgTransfer";
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import org.springframework.web.bind.annotation.PostMapping;
@@ -22,18 +23,22 @@ public class AuthController {
private final AuthFacade authFacade;
private final CellPhoneValidator cellPhoneValidator;
private final EmailValidator emailValidator;
private final PortalUserService portalUserService;
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
PortalUserService portalUserService) {
this.authFacade = authFacade;
this.cellPhoneValidator = cellPhoneValidator;
this.emailValidator = emailValidator;
this.portalUserService = portalUserService;
}
@PostMapping("/request_auth_number")
public ValidationResponse requestAuthNumber(
@RequestParam(required = false) String mobileNumber,
@RequestParam(required = false) String purpose,
HttpSession session) {
ValidationResponse response = new ValidationResponse();
@@ -44,6 +49,16 @@ public class AuthController {
return response;
}
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(mobileNumber)) {
response.setValid(false);
response.setMessage("이미 가입된 휴대폰 번호입니다.");
return response;
}
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
// 이전 세션 데이터 정리
@@ -29,7 +29,8 @@ public class OrgRegisterController {
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_ORG_REGISTER;
}
@@ -74,6 +75,7 @@ public class OrgRegisterController {
if (response.isValid()) {
model.addAttribute("message", response.getMessage());
model.addAttribute("registrationType", "corporate");
return MAIN_REGISTER_RESULT;
} else {
setModelForErrorOrg(model, orgDTO, response.getMessage());
@@ -91,6 +93,7 @@ public class OrgRegisterController {
model.addAttribute("portalOrg", orgDTO);
model.addAttribute("error", errorMessage);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
}
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
import com.eactive.apim.portal.common.dto.ResponseDTO;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.PostMapping;
@@ -18,12 +19,16 @@ import org.springframework.web.bind.annotation.RestController;
public class UserManRestController {
private final UserManFacade userManFacade;
private final CellPhoneValidator cellPhoneValidator;
// 1. Cancel invitation
@PostMapping("/cancel-invitation")
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
boolean notifyConsent = user.isNotifyConsent();
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
// 2. Inactivate user
@@ -40,25 +45,25 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
}
//5. register new user
//5. register new user (휴대폰 번호 기반 초대)
@PostMapping("/invite")
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
String email = newUser.getEmailAddr();
String mobile = newUser.getMobileNumber();
// 이메일 형식 검증
if (email == null || email.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
// 휴대폰 번호 검증
if (mobile == null || mobile.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
}
if (!cellPhoneValidator.isValid(mobile, null)) {
return new ResponseDTO(400, "ERROR", "올바른 휴대폰 번호 형식을 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!email.matches(emailPattern)) {
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
}
boolean notifyConsent = newUser.isNotifyConsent();
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대 메시지가 발송되었습니다."
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
@PostMapping("/change-role")
@@ -69,6 +74,20 @@ 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", "이용자로 변경되었습니다.");
}
// 소속 제외 -> 개인이용자로 전환
@PostMapping("/remove-from-org")
public ResponseDTO removeFromOrg(@RequestBody PortalUserDTO user) {
userManFacade.removeFromOrg(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "소속에서 제외되어 개인이용자로 변경되었습니다.");
}
// 6. Resend invitation
@PostMapping("/resend-invitation")
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
@@ -4,11 +4,14 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
@@ -16,11 +19,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
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.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
@@ -50,6 +57,7 @@ public class UserRegisterController {
private final PortalUserService portalUserService;
private final UserRegisterFacade userRegisterFacade;
private final AuthFacade authFacade;
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final AgreementsFacade agreementsFacade;
@@ -57,6 +65,7 @@ public class UserRegisterController {
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@GetMapping("/signup")
public String showSignupSelection() {
@@ -67,8 +76,6 @@ public class UserRegisterController {
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
boolean isInvited = false;
String orgName = null;
String email = null;
String[] parts = null;
if (invitationToken != null) {
// 8글자 토큰 사용
@@ -80,29 +87,23 @@ public class UserRegisterController {
String orgId = invitation.get().getOrgId();
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
orgName = org.map(PortalOrg::getOrgName).orElse(null);
email = invitation.get().getInvitationEmail();
parts = email.split("@");
}
}
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
model.addAttribute("isInvited", isInvited);
model.addAttribute("orgName", orgName);
model.addAttribute("email", email);
model.addAttribute("loginId", email);
if (parts != null) {
model.addAttribute("emailId", parts[0]);
model.addAttribute("domain", parts[1]);
} else {
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
}
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 는 가입자가 직접 입력한다
model.addAttribute("email", "");
model.addAttribute("loginId", "");
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_USER_REGISTER;
@@ -138,8 +139,21 @@ public class UserRegisterController {
}
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
session.removeAttribute("invitationToken");
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
if (invitationToken == null) {
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
if (registered.isPresent()
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
return "redirect:/signup/verification-email";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return "redirect:/signup/complete";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
return "redirect:/signup/complete/corporate";
} catch (Exception e) {
setModelForError(session, model, agreement, portalUserRegistrationDTO,
"처리 중 오류가 발생했습니다: " + e.getMessage());
@@ -169,6 +183,57 @@ public class UserRegisterController {
return MAIN_EMAIL_COMPLETED;
}
/**
* 회원가입 직후 이메일 인증 페이지.
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
*/
@GetMapping("/signup/verification-email")
public String showSignupVerificationEmail(HttpSession session, Model model) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return "redirect:/login";
}
model.addAttribute("email", email);
return "apps/register/signupVerificationEmail";
}
/**
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
*/
@PostMapping("/signup/send-verification-code")
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
return ResponseEntity.ok(response);
}
/**
* 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다.
*/
@PostMapping("/signup/verify-email-code")
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
String code = request.get("code");
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
if (response.isValid()) {
PortalUser user = portalUserService.findByEmailAddr(email);
portalUserService.activateUser(user.getId());
session.removeAttribute("signupVerificationEmail");
}
return ResponseEntity.ok(response);
}
@GetMapping("/signup/decision")
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
HttpSession session,
@@ -181,6 +246,11 @@ public class UserRegisterController {
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("decisionToken", decodedToken);
// 이미 로그인된 상태면 수락 화면으로 직행
if (SecurityUtil.isAuthenticated()) {
return "redirect:/signup/decision_process";
}
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
@@ -188,9 +258,9 @@ public class UserRegisterController {
return "redirect:/";
}
// 이메일로 사용자 정보 조회
String email = invitation.get().getInvitationEmail();
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
// 휴대폰 번호로 사용자 정보 조회
String mobile = invitation.get().getInvitationMobile();
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
if (!portalUser.isPresent()) {
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
@@ -219,13 +289,14 @@ public class UserRegisterController {
public String showDecisionProcessPage(HttpSession session, Model model) {
session.removeAttribute("decisionToken");
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
if (!invitation.isPresent()) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
@@ -241,7 +312,9 @@ public class UserRegisterController {
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
@@ -260,7 +333,7 @@ public class UserRegisterController {
agreementValidator.validate(agreement, bindingResult);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
@@ -270,13 +343,20 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
} else {
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
portalUserAuthService.reloadCurrentAuthentication();
}
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
session.removeAttribute("pendingInvitation");
session.removeAttribute("pendingInvitationToken");
@@ -294,24 +374,6 @@ public class UserRegisterController {
return "redirect:/";
}
@GetMapping("/validate_email")
public String validateUserEmail(@RequestParam(name = "token", required = false) String token) {
String decodedToken = new String(Base64.decode(token));
try {
String decToken = encryptionUtil.decrypt(decodedToken);
String[] tokens = decToken.split(":");
boolean approvalRequired = portalUserService.activateUser(tokens[1]);
if (approvalRequired) {
return "apps/register/emailValidationResultCorpManager";
} else {
return "apps/register/emailValidationResultUser";
}
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException |
BadPaddingException e) {
throw new IllegalArgumentException("인증 정보 오류. 관리자 문의하세요.");
}
}
// 에러 발생 시 모델 세팅을 위한 private 메서드
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
@@ -325,7 +387,8 @@ public class UserRegisterController {
model.addAttribute("agreement", agreement);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("msgType", "sms");
}
@@ -437,10 +500,10 @@ public class UserRegisterController {
session.removeAttribute("invitationCodeAttempts");
session.removeAttribute("invitationCodeLockoutUntil");
// 기존 사용자 여부 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
if (existingUser.isPresent()) {
if (existingUser) {
// 기존 사용자: 초대 수락 페이지로 이동
return "redirect:/signup/decision?invitation=" + normalizedCode;
} else {
@@ -23,6 +23,9 @@ public class PortalUserDTO implements Serializable {
private String emailAddr;
// 초대 시 알림 수신 동의 여부 (false 면 초대 레코드만 생성, 메시지 미발송)
private boolean notifyConsent;
private PortalOrgDTO portalOrg;
private UserStatus userStatus;
@@ -192,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
// 사용자 생성 및 기관 연결
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
@@ -240,7 +240,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
portalUserRepository.save(existingUser);
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(existingUser);
@@ -274,7 +274,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
portalUserRepository.save(existingUser);
// 새로운 법인용 개인정보수집동의서 저장
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(existingUser);
@@ -7,8 +7,10 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.DurationParser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.event.UserInvitationCancelEvent;
@@ -18,6 +20,8 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.template.service.MessageHandlerService;
@@ -52,6 +56,9 @@ public class UserManFacade {
private final MessageHandlerService messageHandlerService;
private final UserLogRepository userLogRepository;
private final PortalPropertyService portalPropertyService;
private final CellPhoneValidator cellPhoneValidator;
private final UserSessionService userSessionService;
private final PortalUserAuthService portalUserAuthService;
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
return portalUserRepository
@@ -94,23 +101,35 @@ public class UserManFacade {
dto.setMobileNumber(null);
}
public void sendInvitation(PortalUser sender, String emailAddr) {
// 이메일 형식 검증
if (emailAddr == null || emailAddr.trim().isEmpty()) {
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
/**
* 법인 개발자 초대 (휴대폰 번호 기반).
*
* @param sender 초대를 보내는 법인 관리자
* @param mobileRaw 초대 대상 휴대폰 번호 (하이픈 유무 무관)
* @param notifyConsent 알림 수신 동의 여부. false 면 초대 레코드만 생성하고 메시지 발송은 스킵한다.
*/
public void sendInvitation(PortalUser sender, String mobileRaw, boolean notifyConsent) {
// 휴대폰 번호 형식 검증
if (mobileRaw == null || mobileRaw.trim().isEmpty()) {
throw new IllegalArgumentException("휴대폰 번호를 입력해주세요.");
}
if (!cellPhoneValidator.isValid(mobileRaw, null)) {
throw new IllegalArgumentException("올바른 휴대폰 번호 형식을 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!emailAddr.matches(emailPattern)) {
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
}
// 저장 포맷(하이픈 포함, 예: 010-1234-5678)으로 정규화 — PortalUser.mobileNumber 저장 포맷과
// 일치시켜야 결정적 암호화 equality 조회가 매칭된다.
String mobile = normalizeMobile(mobileRaw);
// 중복 초대 확인
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
checkDuplicateInvitation(sender.getPortalOrg().getId(), mobile);
//기존 사용자인지 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
// 기존 사용자인지 휴대폰 번호로 확인 (mobileNumber 는 유니크가 아니므로 다건 가능)
List<PortalUser> matched = portalUserRepository.findAllByMobileNumber(mobile);
if (matched.size() > 1) {
throw new IllegalArgumentException("해당 휴대폰 번호로 가입된 계정이 여러 건 존재하여 초대할 수 없습니다. 관리자에게 문의해주세요.");
}
Optional<PortalUser> existingUser = matched.isEmpty() ? Optional.empty() : Optional.of(matched.get(0));
if (existingUser.isPresent()) {
PortalUser user = existingUser.get();
@@ -119,7 +138,12 @@ public class UserManFacade {
}
}
String token = createInvitation(sender, emailAddr);
String token = createInvitation(sender, mobile);
// 알림 수신 미동의 시 초대 레코드만 생성하고 메시지 발송은 스킵
if (!notifyConsent) {
return;
}
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
MessageRecipient recipient;
@@ -129,7 +153,7 @@ public class UserManFacade {
params.put("corpName", sender.getPortalOrg().getOrgName());
params.put("authNumber", token); // 8-character invitation code
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 휴대폰 번호 사용
if (existingUser.isPresent()) {
PortalUser user = existingUser.get();
params.put("loginId", user.getUserName());
@@ -137,26 +161,36 @@ public class UserManFacade {
params.put("url", "signup/decision?invitation=" + token);
recipient = MessageRecipient.of(user);
// MessageRecipient.of() 는 유선전화(phoneNumber)를 채우므로 휴대폰 번호로 보정
recipient.setPhone(user.getMobileNumber());
} else {
params.put("loginId", emailAddr);
params.put("NAME", emailAddr);
params.put("loginId", mobile);
params.put("NAME", mobile);
params.put("url", "signup/portalUser?invitation=" + token);
String emailUsername = emailAddr.contains("@") ? emailAddr.substring(0, emailAddr.indexOf("@")) : emailAddr;
recipient = MessageRecipient.builder()
.userId(emailAddr)
.username(emailUsername)
.userId(mobile)
.username(mobile)
.phone(mobile)
.build();
}
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
}
public String createInvitation(PortalUser sender, String emailAddr) {
/**
* 휴대폰 번호를 저장 포맷(하이픈 포함)으로 정규화한다.
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
*/
private String normalizeMobile(String raw) {
return PhoneNumberUtil.normalize(raw);
}
public String createInvitation(PortalUser sender, String mobile) {
UserInvitation newInvitation = new UserInvitation();
newInvitation.setAdminId(sender.getId());
newInvitation.setOrgId(sender.getPortalOrg().getId());
newInvitation.setInvitationEmail(emailAddr);
newInvitation.setInvitationMobile(mobile);
// Set additional required fields
newInvitation.setInvitationDate(LocalDateTime.now());
@@ -215,6 +249,10 @@ public class UserManFacade {
portalUserRepository.save(targetUser);
portalUserRepository.save(currentUser);
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
portalUserAuthService.reloadCurrentAuthentication();
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(targetUser.getEmailAddr());
recipient.setPhone(targetUser.getMobileNumber());
@@ -225,7 +263,57 @@ public class UserManFacade {
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId) {
/**
* 법인 관리자 권한 회수 (관리자 → 이용자).
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
*/
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
if (targetUser.getPortalOrg() == null
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
throw new IllegalArgumentException("잘 못 된 접근입니다");
}
if (targetUser.getId().equals(admin.getId())) {
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
}
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
}
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
portalUserRepository.save(targetUser);
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
/**
* 소속 제외 (기관에서 제외 → 개인이용자).
* 대상은 본인이 아닌 같은 기관의 사용자여야 한다. portalOrg 를 비우고 ROLE_USER 로 전환한다.
*/
public void removeFromOrg(PortalAuthenticatedUser admin, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
if (targetUser.getPortalOrg() == null
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
throw new IllegalArgumentException("잘 못 된 접근입니다");
}
if (targetUser.getId().equals(admin.getId())) {
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
}
targetUser.setPortalOrg(null);
targetUser.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(targetUser);
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
UserInvitation invitation = userInvitationRepository.findById(invitationId)
.orElseThrow(() -> new NotFoundException("Invitation not found with id: " + invitationId));
@@ -237,17 +325,19 @@ public class UserManFacade {
throw new IllegalStateException("Invitation is not in a cancelable state");
}
// invitationId 로 사용자 이름 조회, 실패시 id 를 이름으로 대체
String name = portalUserRepository.findById(invitation.getInvitationEmail())
.map(PortalUser::getUserName)
.orElse(invitation.getInvitationEmail());
invitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(invitation);
// name 이 이메일 일 경우 @ 앞부분 만 사용 (안하면 암호화 해야함...)
if (name.contains("@")) {
name = name.substring(0, name.indexOf("@"));
// 알림 수신 미동의 시 취소 알림 메시지 발송은 스킵
if (!notifyConsent) {
return;
}
invitation.setStatus(InvitationStatus.CANCELED);
// invitationMobile 로 사용자 이름 조회, 실패시 마스킹된 번호로 대체
String name = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
.stream().findFirst()
.map(PortalUser::getUserName)
.orElse(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
messageHandlerService.publishEvent(UserInvitationCancelEvent.KEY,
MessageRecipient.builder()
@@ -260,8 +350,6 @@ public class UserManFacade {
}}
);
userInvitationRepository.save(invitation);
}
public void inactivateUser(PortalAuthenticatedUser admin, String userId) {
@@ -298,8 +386,8 @@ public class UserManFacade {
.stream()
.map(invitation -> {
PortalUserDTO dto = portalUserMapper.pendingUserVO(invitation);
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(invitation.getInvitationEmail()));
dto.setEmailAddr(null); // Clear unmasked email
dto.setMaskedMobileNumber(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
dto.setMobileNumber(null); // Clear unmasked mobile
return dto;
})
.collect(Collectors.toList());
@@ -328,32 +416,32 @@ public class UserManFacade {
existingInvitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(existingInvitation);
// 새로운 초대 생성 및 발송
sendInvitation(sender, existingInvitation.getInvitationEmail());
// 새로운 초대 생성 및 발송 (재발송 시 최초 동의값을 알 수 없으므로 발송함을 기본으로)
sendInvitation(sender, existingInvitation.getInvitationMobile(), true);
}
/**
* 중복 초대 확인
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
* 같은 기관에서 같은 휴대폰 번호로 이미 PENDING 상태인 초대가 있는지 확인
*/
private void checkDuplicateInvitation(String orgId, String emailAddr) {
private void checkDuplicateInvitation(String orgId, String mobile) {
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
emailAddr, orgId, InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndOrgIdAndStatus(
mobile, orgId, InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
throw new IllegalArgumentException("이미 초대가 진행 중인 휴대폰 번호입니다.");
}
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> otherOrgPendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
emailAddr, InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
mobile, InvitationStatus.PENDING);
if (otherOrgPendingInvitation.isPresent()
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
throw new IllegalArgumentException("해당 휴대폰 번호는 다른 기관에서 초대 진행 중입니다.");
}
}
@@ -137,12 +137,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
}
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
//
// if(existingUser != null) {
// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
// }
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
@@ -150,7 +149,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_IND);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
@@ -173,6 +172,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"입력값을 확인해 주세요.");
}
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
if(existingUser != null) {
@@ -190,7 +195,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.deleteUserAgreements(newUser.getId()); //이전에 동의한 내역은 삭제
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
invitation.setStatus(UserInvitationEnums.InvitationStatus.COMPLETED);
invitation.setCompleteDate(LocalDateTime.now());
@@ -242,7 +247,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
try {
if ("accept".equals(action)) {
// 수락 처리
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail())
PortalUser user = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다."));
// 서비스 계층에 위임
@@ -253,11 +259,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
userInvitationRepository.save(invitation);
agreementsFacade.deleteUserAgreements(user.getId());
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT);
ValidationResponse response = new ValidationResponse();
response.setValid(true);
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
response.setShowPopup(true);
response.setPopupType("success");
return response;
@@ -15,7 +15,7 @@ public abstract class PortalUserMapper implements GenericMapper<PortalUserDTO, P
public abstract PortalAuthenticatedUser portalUserToAuthenticatedUser(PortalUser portalUser);
@Mapping(target = "emailAddr", source = "invitationEmail")
@Mapping(target = "mobileNumber", source = "invitationMobile")
public abstract PortalUserDTO pendingUserVO(UserInvitation invitation);
public PortalUserDTO toDTO(PortalUser portalUser) {
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
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.portaluser.entity.PortalUser;
@@ -30,7 +31,9 @@ import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -58,21 +61,46 @@ public class PortalUserAuthService implements UserDetailsService {
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
String normalizedUsername = username != null ? username.toLowerCase() : null;
PortalUser portalUser = findByEmailAddr(normalizedUsername);
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
}
}
/**
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층)을 채운 PortalAuthenticatedUser 를 생성한다.
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
}
/**
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext 의 Authentication 을 교체한다.
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 등)을 즉시 반영한다.
*/
public void reloadCurrentAuthentication() {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
if (current == null) {
return;
}
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
UsernamePasswordAuthenticationToken newAuth =
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
newAuth.setDetails(refreshed);
SecurityContextHolder.getContext().setAuthentication(newAuth);
}
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
try {
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
@@ -122,37 +150,6 @@ public class PortalUserAuthService implements UserDetailsService {
messageHandlerService.publishEvent(UserPasswordResetEvent.KEY, recipient, params);
}
public void resendVerificationEmail(String loginId) {
PortalUser user = portalUserRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ACTIVE)) {
throw new IllegalArgumentException("이미 인증이 완료된 계정입니다.");
}
// Enum을 직접 전달
List<MessageRequest> messageRequests = messageRequestRepository.findByEmailAndMessageCode(
user.getLoginId(), MessageCode.USER_VERIFICATION_EMAIL
);
if (!messageRequests.isEmpty()) {
messageRequestRepository.deleteAll(messageRequests);
}
HashMap<String, Object> params = new HashMap<>();
String tokenValue = user.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + user.getId();
try {
String encToken = encryptionUtil.encrypt(tokenValue);
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
MessageRecipient recipient = createMessageRecipient(user);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new SystemException("암호화 모듈 오류");
}
}
private MessageRecipient createMessageRecipient(PortalUser user) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(user.getUserName());
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.service;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -24,6 +25,12 @@ import org.springframework.util.StringUtils;
@Service
@Transactional
public class PortalUserService {
/** PortalProperty 그룹명 */
private static final String PORTAL_PROPERTY_GROUP = "Portal";
/** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */
private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow";
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final PasswordEncoder passwordEncoder;
@@ -68,6 +75,33 @@ public class PortalUserService {
return portalUserRepository.existsByLoginId(normalizedLoginId);
}
/**
* 휴대폰 번호 중복 여부 확인.
* mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다.
* (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다)
*/
public boolean existsByMobileNumber(String mobileNumber) {
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
return false;
}
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
}
/**
* 회원가입 시 휴대폰 번호 중복 차단 활성 여부.
* 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다.
* - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성
* - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false)
*/
@Transactional(readOnly = true)
public boolean isMobileDuplicateCheckEnabled() {
String allow = portalPropertyService
.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP)
.getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false");
boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow);
return !allowed;
}
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
return portalUserRepository.countByPortalOrg(portalOrg) > 1;
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -21,6 +22,9 @@ public class GlobalControllerAdvice {
@Autowired
private PortalProperties portalProperties;
@Autowired
private UserSessionService userSessionService;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -58,4 +62,12 @@ public class GlobalControllerAdvice {
}
return null;
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(). PortalProperty(Portal/session.timeout.minutes)에서 조회.
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
return userSessionService.getSessionTimeoutMinutes();
}
}
@@ -1,6 +1,8 @@
package com.eactive.apim.portal.common.exception;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -9,6 +11,8 @@ import org.springframework.web.servlet.ModelAndView;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import java.io.PrintWriter;
import java.io.StringWriter;
/**
* Created by Sungpil Hyun
@@ -16,6 +20,11 @@ import javax.servlet.http.HttpServletRequest;
@Controller
public class PortalErrorController implements ErrorController {
private final Environment environment;
public PortalErrorController(Environment environment) {
this.environment = environment;
}
@RequestMapping(value = "/error", method = {RequestMethod.POST, RequestMethod.GET})
public ModelAndView handleError(HttpServletRequest request) {
@@ -27,10 +36,134 @@ public class PortalErrorController implements ErrorController {
}
ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("status", status);
if (isProd()) {
// prod 환경에서는 내부 오류 정보를 노출하지 않고 이전과 동일한 일반 안내 메시지만 표시
modelAndView.addObject("errorTitle", "일시적인 장애로 서비스가 중단되었습니다.");
modelAndView.addObject("errorDescription", "서비스 이용에 불편을 드려 죄송합니다. 빠른 서비스를 제공할 수 있게 준비하겠습니다.");
} else {
modelAndView.addObject("errorTitle", resolveTitle(status));
modelAndView.addObject("errorDescription", resolveDescription(status));
modelAndView.addObject("errorMessage", resolveErrorMessage(request, status));
modelAndView.addObject("activeProfile", resolveActiveProfile());
// include-stacktrace=always(로컬 디버깅 프로파일) 때만 화면에 스택 트레이스까지 노출
if (isErrorDetailEnabled()) {
Throwable throwable = resolveThrowable(request);
if (throwable != null) {
modelAndView.addObject("errorException", throwable.getClass().getName());
modelAndView.addObject("errorStackTrace", stackTraceAsString(throwable));
}
}
}
return modelAndView;
}
return new ModelAndView("redirect:/");
}
private boolean isProd() {
return environment.acceptsProfiles(Profiles.of("prod"));
}
/**
* server.error.include-stacktrace=always 경우에만 화면에 스택 트레이스를 노출한다.
* (로컬 디버깅 프로파일에서만 해당 값을 always 설정 운영/스테이징에는 노출되지 않음)
*/
private boolean isErrorDetailEnabled() {
String includeStacktrace = environment.getProperty("server.error.include-stacktrace", "never");
return "always".equalsIgnoreCase(includeStacktrace);
}
private String resolveActiveProfile() {
String[] activeProfiles = environment.getActiveProfiles();
if (activeProfiles.length == 0) {
return "default";
}
return String.join(", ", activeProfiles);
}
private Throwable resolveThrowable(HttpServletRequest request) {
Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
if (exception instanceof Throwable) {
return (Throwable) exception;
}
return null;
}
private String stackTraceAsString(Throwable throwable) {
StringWriter stringWriter = new StringWriter();
throwable.printStackTrace(new PrintWriter(stringWriter));
return stringWriter.toString();
}
private String resolveTitle(Object status) {
HttpStatus httpStatus = resolveHttpStatus(status);
if (httpStatus == null) {
return "서비스 처리 중 오류가 발생했습니다.";
}
if (httpStatus == HttpStatus.FORBIDDEN) {
return "접근이 거부되었습니다.";
}
if (httpStatus == HttpStatus.NOT_FOUND) {
return "요청하신 페이지를 찾을 수 없습니다.";
}
if (httpStatus.is4xxClientError()) {
return "요청을 처리할 수 없습니다.";
}
if (httpStatus.is5xxServerError()) {
return "서비스 처리 중 오류가 발생했습니다.";
}
return httpStatus.getReasonPhrase();
}
private String resolveDescription(Object status) {
HttpStatus httpStatus = resolveHttpStatus(status);
if (httpStatus == HttpStatus.FORBIDDEN) {
return "요청이 보안 검증을 통과하지 못했습니다.";
}
if (httpStatus == HttpStatus.NOT_FOUND) {
return "주소가 변경되었거나 삭제되었을 수 있습니다.";
}
if (httpStatus != null && httpStatus.is5xxServerError()) {
return "잠시 후 다시 시도해 주세요.";
}
return "요청 내용을 확인한 뒤 다시 시도해 주세요.";
}
private String resolveErrorMessage(HttpServletRequest request, Object status) {
Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
HttpStatus httpStatus = resolveHttpStatus(status);
StringBuilder message = new StringBuilder();
message.append("HTTP ").append(status);
if (httpStatus != null) {
message.append(" ").append(httpStatus.getReasonPhrase());
}
if (requestUri != null) {
message.append(" / 요청 경로: ").append(requestUri);
}
Object exceptionMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
if (exceptionMessage != null && !exceptionMessage.toString().trim().isEmpty()) {
message.append(" / 원인: ").append(exceptionMessage);
} else if (httpStatus == HttpStatus.FORBIDDEN) {
message.append(" / 원인: 접근 권한 또는 CSRF 토큰을 확인하세요.");
}
return message.toString();
}
private HttpStatus resolveHttpStatus(Object status) {
if (status instanceof Integer) {
return HttpStatus.resolve((Integer) status);
}
try {
return HttpStatus.resolve(Integer.parseInt(status.toString()));
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,194 @@
package com.eactive.apim.portal.common.migration;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
import javax.servlet.http.HttpServletRequest;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
*
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}
* 평문인코딩, 인코딩동일값(멱등)으로 정규화된다. 값이 기존과 다를 때만 UPDATE 한다.</p>
*
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
* 실제 실행은 {@code dryRun=false} 명시해야 한다. 작업 완료 클래스는 제거한다.</p>
*
* <pre>
* # 미리보기(변경 )
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
* # 실제 실행 (PII 컬럼)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
* </pre>
*/
@Slf4j
@RestController
@RequestMapping("/internal/migration")
public class LegacyEncryptionMigrationController {
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
);
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
private static final List<String> AUDIT_TABLES = Arrays.asList(
"PTL_USER", "ptl_faq", "ptl_org",
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
);
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
private final JdbcTemplate jdbcTemplate;
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
}
@PostMapping("/encrypt-legacy")
@Transactional("transactionManager")
public Map<String, Object> encryptLegacy(HttpServletRequest request,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "false") boolean includeAudit) {
assertLocalOnly(request);
assertNotBypass();
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
if (includeAudit) {
for (String table : AUDIT_TABLES) {
targets.add(new TargetTable(table, AUDIT_COLUMNS));
}
}
List<Map<String, Object>> results = new ArrayList<>();
int totalChanged = 0;
for (TargetTable target : targets) {
for (String column : target.columns) {
Map<String, Object> r = processColumn(target.table, column, dryRun);
results.add(r);
totalChanged += (int) r.get("changed");
}
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
response.put("includeAudit", includeAudit);
response.put("totalChanged", totalChanged);
response.put("results", results);
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
return response;
}
/**
* 단일 (테이블, 컬럼) 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
*/
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("table", table);
r.put("column", column);
List<String> values;
try {
values = jdbcTemplate.queryForList(
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
String.class);
} catch (Exception e) {
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
r.put("distinct", 0);
r.put("changed", 0);
r.put("error", e.getMessage());
return r;
}
int changed = 0;
for (String value : values) {
String normalized;
try {
// 평문 인코딩, 이미 인코딩 동일값 (멱등)
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
} catch (Exception e) {
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
continue;
}
if (normalized != null && !normalized.equals(value)) {
if (!dryRun) {
jdbcTemplate.update(
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
normalized, value);
}
changed++;
}
}
r.put("distinct", values.size());
r.put("changed", changed);
return r;
}
/**
* damo-manager bypass 모드면 실행 자체를 거부한다.
* bypass 에서는 {@code encrypt} 무변환(원문 그대로)이라 정규화가 평문평문 no-op 되어
* 마이그레이션이 의미가 없고, "완료" 오인될 위험이 있다. real/fake 모드로 기동 실행해야 한다.
*/
private void assertNotBypass() {
if (converter.isBypassMode()) {
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
throw new ResponseStatusException(HttpStatus.CONFLICT,
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
}
}
/**
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
*/
private void assertLocalOnly(HttpServletRequest request) {
String remote = request.getRemoteAddr();
boolean localAddr = "127.0.0.1".equals(remote)
|| "0:0:0:0:0:0:0:1".equals(remote)
|| "::1".equals(remote);
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
if (!localAddr || viaProxy) {
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
}
}
private static final class TargetTable {
final String table;
final List<String> columns;
TargetTable(String table, List<String> columns) {
this.table = table;
this.columns = columns;
}
}
}
@@ -1,10 +1,24 @@
package com.eactive.apim.portal.common.util;
import java.util.Arrays;
/**
* 문자열 마스킹 처리를 위한 유틸리티 클래스
*
* <p>보안아키텍처 표준 마스킹 규칙(elink-online-core {@code MaskingUtils}) 동일한 알고리즘을
* 구현한다. eapim-portal은 elink-online-core에 의존하지 않으므로 로직을 직접 포함한다.
* 소유자 본인이 자신의 정보를 조회하는 경우 마스킹하지 않는 owner 인지 동작은 유지한다.</p>
*
* <pre>
* 이름 2자: 1자리(가나*) / 3자 이상: 앞뒤 제외 가운데 전체(가나다라**)
* 이메일 아이디 앞2·뒤2 마스킹, 4자 이하 전체(test****, test123**st1**)
* 전화 2·3번째 세그먼트 각각 2자리(010-1234-1234010-12**-12**)
* </pre>
*/
public class StringMaskingUtil {
private static final char MASK = '*';
// ID 비교
private static boolean isOwner(String ownerId, String currentUserId) {
return currentUserId != null && ownerId != null && currentUserId.equals(ownerId);
@@ -21,8 +35,15 @@ public class StringMaskingUtil {
return name;
}
return name.length() > 1 ?
name.substring(0, name.length() - 1) + "*" : name;
int len = name.length();
if (len == 1) {
return name;
}
if (len == 2) {
return name.charAt(0) + String.valueOf(MASK);
}
// 3자 이상: + 가운데 전체 마스킹 + 마지막
return name.charAt(0) + stars(len - 2) + name.charAt(len - 1);
}
// 이메일 마스킹 처리
@@ -31,32 +52,32 @@ public class StringMaskingUtil {
return email;
}
String[] parts = email.split("@", 2);
if (parts.length != 2) {
return email;
int atIdx = email.indexOf('@');
String local = email.substring(0, atIdx);
String domain = email.substring(atIdx);
if (local.length() <= 4) {
return stars(local.length()) + domain;
}
String localPart = parts[0];
String maskedLocal;
if (localPart.length() <= 2) {
maskedLocal = "***";
} else {
maskedLocal = localPart.substring(0, localPart.length() - 3) + "***";
}
return maskedLocal + "@" + parts[1];
return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain;
}
// 휴대폰 번호 마스킹 처리
// 휴대폰/전화/FAX 번호 마스킹 처리
public static String maskMobileNumber(String number, String ownerId, String currentUserId) {
if (!isValidString(number) || isOwner(ownerId, currentUserId)) {
return number;
}
String[] parts = number.split("-", 3);
return parts.length == 3 ?
parts[0] + "-***-" + parts[2] : number;
boolean hasHyphen = number.contains("-");
String normalized = hasHyphen ? number : normalizePhoneNumber(number);
String[] parts = normalized.split("-");
if (parts.length == 3) {
String m0 = parts[0];
String m1 = maskSegmentTail(parts[1], 2);
String m2 = maskSegmentTail(parts[2], 2);
return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2;
}
return number;
}
// 기존 메서드 오버로딩 (하위 호환성)
@@ -71,4 +92,41 @@ public class StringMaskingUtil {
public static String maskMobileNumber(String number) {
return maskMobileNumber(number, null, null);
}
}
// =========================================================================
// private helpers
// =========================================================================
private static String stars(int count) {
if (count <= 0) return "";
char[] arr = new char[count];
Arrays.fill(arr, MASK);
return new String(arr);
}
private static String maskSegmentTail(String segment, int count) {
if (segment.length() <= count) return stars(segment.length());
return segment.substring(0, segment.length() - count) + stars(count);
}
/**
* 한국 전화번호(숫자만) XXX-XXXX-XXXX 형식으로 정규화
* <pre>
* 02 + 7자리 02-XXX-XXXX
* 02 + 8자리 02-XXXX-XXXX
* 0XX + 7자리 0XX-XXX-XXXX
* 0XX + 8자리 0XX-XXXX-XXXX
* </pre>
*/
private static String normalizePhoneNumber(String digits) {
int len = digits.length();
if (digits.startsWith("02")) {
if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5);
if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6);
} else if (digits.startsWith("0")) {
if (len == 10) return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
if (len == 11) return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
}
return digits;
}
}
@@ -1,8 +1,10 @@
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;
@@ -49,6 +51,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -93,9 +96,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
// 휴대폰 형식(하이픈 유무) 달라도 초대와 매칭되도록 정규화 조회
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getLoginId(), InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
UserInvitation invitation = pendingInvitation.get();
@@ -112,6 +116,12 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
}
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 로그인 성공 세션 정보 로깅
logLoginSuccess(request, session, username);
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -10,10 +11,12 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -33,15 +36,30 @@ public class PortalConfigSecurity {
private final PortalLogoutSuccessHandler logoutSuccessHandler;
private final SessionValidationFilter sessionValidationFilter;
@Autowired
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
PortalAuthenticationManager portalAuthenticationManager,
PortalLogoutSuccessHandler logoutSuccessHandler) {
PortalLogoutSuccessHandler logoutSuccessHandler,
SessionValidationFilter sessionValidationFilter) {
this.authenticationFailureHandler = authenticationFailureHandler;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.portalAuthenticationManager = portalAuthenticationManager;
this.logoutSuccessHandler = logoutSuccessHandler;
this.sessionValidationFilter = sessionValidationFilter;
}
/**
* {@link SessionValidationFilter} Spring Security 필터 체인에서만 동작하도록
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
*/
@Bean
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
@@ -64,6 +82,13 @@ public class PortalConfigSecurity {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
try {
// CSRF 토큰을 쿠키(XSRF-TOKEN) 대신 HTTP 세션에 저장한다.
// 운영(prod/eapim/devportal) 동일 호스트(IP:PORT) 여러 서비스가 있어
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 로그인 403이 발생했다.
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터) 호환되도록 헤더명을 고정한다.
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
http
.authenticationManager(portalAuthenticationManager)
.formLogin(form -> form
@@ -75,21 +100,28 @@ public class PortalConfigSecurity {
.successHandler(authenticationSuccessHandler))
.logout(logout -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/actionLogout.do", "GET"))
// LogoutHandler 기본 SecurityContextLogoutHandler(세션 무효화)보다 먼저 실행된다.
// 세션이 살아있는 시점에 DB 세션 레코드를 정리해야 중복세션 오탐("이미 접속중") 막는다.
.addLogoutHandler(logoutSuccessHandler)
.logoutSuccessHandler(logoutSuccessHandler))
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소) 타임아웃되면
// 로그인 제출 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true) 보낸다.
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // Allow only one session per user
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
.expiredUrl("/login?expired") // Redirect when session expires
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
.and()
.sessionFixation()
.changeSessionId()
);
)
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
} catch (Exception e) {
@@ -97,9 +129,21 @@ public class PortalConfigSecurity {
}
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
/**
* CSRF 토큰 누락/불일치는 "권한없음" 아니라 (대개) 세션 만료다.
* 로그인 페이지에 오래 머물러 세션 CSRF 토큰이 사라진 경우가 대표적이므로
* 기존 세션 만료 안내(login.html의 {@code param.expired}) 재사용해 오해를 막는다.
* 진짜 권한 위반(@Secured ) 기본 핸들러에 위임해 403을 유지한다.
*/
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
return (request, response, accessDeniedException) -> {
if (accessDeniedException instanceof CsrfException) {
response.sendRedirect(request.getContextPath() + "/login?expired=true");
return;
}
delegate.handle(request, response, accessDeniedException);
};
}
@Bean
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.converter.EnabledStatusConverter;
import java.util.List;
import java.util.concurrent.TimeUnit;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.format.FormatterRegistry;
@@ -18,10 +21,13 @@ import org.springframework.web.filter.CharacterEncodingFilter;
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.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
import org.springframework.web.servlet.resource.VersionResourceResolver;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
@@ -32,6 +38,22 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public static final String ERROR = "error";
private final Environment environment;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@Value("${app.resource-versioning.enabled:true}")
private boolean resourceVersioningEnabled;
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
// prod 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
// 개발환경에서 OFF sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
@Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled;
public PortalConfigWebDispatcherServlet(Environment environment) {
this.environment = environment;
}
@Bean
@@ -61,16 +83,64 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
}
registry.addResourceHandler("/css/**").addResourceLocations("/css/", "classpath:/static/css/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/webfonts/**").addResourceLocations("/webfonts/", "classpath:/static/webfonts/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/font/**").addResourceLocations("/font/", "classpath:/static/font/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/html/**").addResourceLocations("/html/", "classpath:/static/html/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
/**
* 정적자원 핸들러 공통 등록.
*
* <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 체인 앞단에 추가하여
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 매핑한다.
* 내용이 바뀌면 URL 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 해시 URL 치환한다.</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) no-store 내려보낸다.
// no-store 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
CacheControl cacheControl = isResourceCachingEnabled()
? CacheControl.maxAge(1, TimeUnit.DAYS)
: CacheControl.noStore();
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(cacheControl)
.resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
chain.addResolver(new PathResourceResolver());
}
/**
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
* 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
*/
private boolean isResourceVersioningEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceVersioningEnabled;
}
/**
* 정적자원 서버측 캐싱(ResourceChain CachingResourceResolver) 활성 여부.
* prod 프로파일은 토글과 무관하게 항상 ON, 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
*
* <p>OFF 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
* ({@code spring.web.resources.cache} 브라우저 캐시 헤더만 제어할 서버측 캐시와는 무관하다.)</p>
*/
private boolean isResourceCachingEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceCachingEnabled;
}
@@ -123,4 +193,13 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
/**
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 치환하기 위한 필터.
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 등록한다.
*/
@Bean
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
return new ResourceUrlEncodingFilter();
}
}
@@ -1,10 +1,12 @@
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.StringRepeatUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
@@ -18,20 +20,37 @@ import java.time.format.DateTimeFormatter;
import java.util.Enumeration;
/**
* 로그아웃 성공 세션 정보를 로깅하는 핸들러
* 로그아웃 처리 핸들러.
*
* <p>DB 세션 레코드 정리와 로깅은 {@link LogoutHandler#logout}에서 수행한다. 시점은 기본
* {@code SecurityContextLogoutHandler} HTTP 세션을 무효화하기 <b></b>이라 세션이 유효하다.
* (성공 핸들러 {@link #onLogoutSuccess} 무효화 <b></b> 실행되어 {@code getSession(false)}
* null 되므로, 거기서 정리하면 DB 행이 남아 재로그인 "이미 접속중" 오탐이 발생한다.)
*/
@Component
public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessHandler {
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 UserSessionService userSessionService;
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
this.userSessionService = userSessionService;
}
/**
* 세션 무효화 전에 실행되어 DB 세션 레코드를 정리하고 로그아웃 정보를 로깅한다.
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
public void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
HttpSession session = request.getSession(false);
if (session != null) {
// DB 세션 레코드 정리 (중복세션 오탐 방지)
userSessionService.removeSession(session.getId());
StringBuilder logMessage = new StringBuilder();
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
@@ -97,7 +116,14 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
sessionLogger.info(logMessage.toString());
}
}
/**
* 로그아웃(세션 무효화) 완료 메인으로 리다이렉트한다.
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
response.sendRedirect(request.getContextPath() + "/");
}
}
@@ -0,0 +1,51 @@
package com.eactive.apim.portal.config;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.system.ApplicationPid;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
/**
* Prints a startup banner to stdout once boot is complete.
*
* <p>Uses {@link System#out} directly (not a logger) so the info is shown even when
* the logback console appender is disabled (CONSOLE_LOG_ENABLED=false).
*/
@Component
public class StartupInfoPrinter implements ApplicationListener<ApplicationReadyEvent> {
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
Environment env = event.getApplicationContext().getEnvironment();
String port = env.getProperty("server.port", "8080");
String contextPath = env.getProperty("server.servlet.context-path", "/");
if (!contextPath.startsWith("/")) {
contextPath = "/" + contextPath;
}
String url = "http://localhost:" + port + contextPath;
String[] active = env.getActiveProfiles();
String profile = active.length == 0
? String.join(", ", env.getDefaultProfiles())
: String.join(", ", active);
String instanceName = env.getProperty("inst.Name", System.getProperty("inst.Name", "unknown"));
String pid = new ApplicationPid().toString();
StringBuilder sb = new StringBuilder();
sb.append(System.lineSeparator());
sb.append("=====================================================").append(System.lineSeparator());
sb.append(" EAPIM Portal - startup complete").append(System.lineSeparator());
sb.append("=====================================================").append(System.lineSeparator());
sb.append(" URL : ").append(url).append(System.lineSeparator());
sb.append(" Spring profile : ").append(profile).append(System.lineSeparator());
sb.append(" Instance name : ").append(instanceName).append(System.lineSeparator());
sb.append(" PID : ").append(pid).append(System.lineSeparator());
sb.append("=====================================================").append(System.lineSeparator());
System.out.println(sb);
System.out.flush();
}
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
@EMSDataSource
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRoleContaining(@Param("role") String role);
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.djb.community.qna.comment.service;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* Q&A 등록/댓글 등록 portal-admin 역할(TSEAIRM02) 가진 관리자 전원에게
* 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommunityAdminNotifier {
private final UserInfoRepository userInfoRepository;
private final MessageHandlerService messageHandlerService;
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
try {
List<UserInfo> admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
if (admins == null || admins.isEmpty()) {
log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
return;
}
for (UserInfo admin : admins) {
try {
MessageRecipient recipient = toRecipient(admin);
messageHandlerService.publishEvent(code, recipient, params);
} catch (Exception e) {
log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
admin.getUserid(), code.name(), e);
}
}
} catch (Exception e) {
log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
}
}
private MessageRecipient toRecipient(UserInfo admin) {
MessageRecipient r = new MessageRecipient();
r.setUsername(admin.getUsername());
r.setUserId(admin.getEmad());
r.setPhone(admin.getCphnno());
r.setMessengerId(admin.getUserid());
return r;
}
}
@@ -13,8 +13,6 @@ import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.qna.entity.InquiryComment;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -44,7 +42,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
private final MessageHandlerService messageHandlerService;
private final CommunityAdminNotifier adminNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
@@ -96,21 +94,12 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
}
private void publishAdminNotification(Inquiry inquiry, InquiryComment comment, PortalAuthenticatedUser current) {
try {
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("commentContent", comment.getCommentDetail());
params.put("writerName", current.getUserName());
messageHandlerService.publishEvent(
MessageCode.INQUIRY_COMMENT_CREATED_ADMIN,
MessageRecipient.of(current),
params
);
} catch (Exception e) {
log.warn("Q&A 댓글 등록 관리자 알림 발행 실패 — inquiryId={}, commentId={}",
inquiry.getId(), comment.getId(), e);
}
Map<String, Object> params = new HashMap<>();
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("commentContent", comment.getCommentDetail());
params.put("writerName", current.getUserName());
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
}
private Map<String, String> resolveWriterNames(List<InquiryComment> comments) {
@@ -0,0 +1,10 @@
package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbAdminRole {
/** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
public static final String PORTAL_ADMIN = "portal-admin";
private DjbAdminRole() {
}
}
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbInquiryStatus {
public static final String PENDING = "PENDING";
public static final String IN_PROGRESS = "IN_PROGRESS";
public static final String RESPONDED = "RESPONDED";
public static final String CLOSED = "CLOSED";
private DjbInquiryStatus() {
@@ -0,0 +1,37 @@
package com.eactive.apim.portal.djb.testbed.advice;
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedAuthController;
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedSpecController;
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 테스트베드 컨트롤러 한정 예외 JSON 변환.
* (qna 로컬 핸들러와 동일한 {@code {code, message}} 포맷.)
*/
@RestControllerAdvice(assignableTypes = {DjbTestbedAuthController.class, DjbTestbedSpecController.class})
public class DjbTestbedExceptionHandler {
@ExceptionHandler(DjbUnsupportedAuthTypeException.class)
public ResponseEntity<Map<String, Object>> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) {
Map<String, Object> body = new HashMap<>();
body.put("code", "UNSUPPORTED_AUTHTYPE");
body.put("message", ex.getMessage());
body.put("authtype", ex.getAuthtype());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
Map<String, Object> body = new HashMap<>();
body.put("code", "FORBIDDEN");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}
@@ -0,0 +1,157 @@
package com.eactive.apim.portal.djb.testbed.config;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 테스트베드 게이트웨이 설정을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.gateway.})
* 에서 읽는 얇은 접근자. 별도 property-injection 인프라 대신 기존
* {@link PortalPropertyService#getOrCreateProperty} 직접 사용한다(사용자 지시).
*
* <p>{@code getOrCreateProperty} DB row 미존재 default INSERT(그룹 존재 )하지만
* <em>공백 보정은 하지 않으므로</em>, 여기 {@link #resolve} 에서 {@code isBlank default} 폴백을 둔다.
*
* <p>-Spring 컴포넌트인 {@code ApiTesterFilter}(@WebFilter)
* {@code ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class)} 접근한다.
*/
@Component
@RequiredArgsConstructor
public class DjbTestbedGatewayProperty {
private final PortalPropertyService portalPropertyService;
private static final String GROUP = "Portal";
public static final String KEY_BASE_URL = "djb.gateway.base-url";
public static final String KEY_TOKEN_PATH = "djb.gateway.token-path";
public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header";
public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header";
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 DEFAULT_BASE_URL = "PortalMock";
public static final String DEFAULT_TIMEOUT_SEC = "10";
public static final String DEFAULT_USE_PROXY = "true";
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
private static final int TIMEOUT_FALLBACK_MS = 10_000;
/** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */
public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token";
public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key";
/** 게이트웨이 API 호출 시 액세스 토큰 헤더명 (가이드 v0.2: Authorization → X-AUTH-TOKEN). */
public static final String DEFAULT_OAUTH_HEADER = "X-AUTH-TOKEN";
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
public String baseUrl() {
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
}
public String tokenPath() {
return resolve(KEY_TOKEN_PATH, DEFAULT_TOKEN_PATH,
"OAuth 토큰 발급 경로 (GW 모드에서만 사용)");
}
public String apiKeyHeader() {
return resolve(KEY_API_KEY_HEADER, DEFAULT_API_KEY_HEADER,
"API Key 전달 헤더명");
}
public String oauthHeader() {
return resolve(KEY_OAUTH_HEADER, DEFAULT_OAUTH_HEADER,
"OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)");
}
/**
* 테스트베드 API 호출 서버 프록시({@code /api/call-api}) 사용 여부.
* {@code false} 이면 브라우저에서 대상 주소로 직접 호출(CORS 허용 필요). 기본 true.
*/
public boolean useProxy() {
return parseBool(resolve(KEY_USE_PROXY, DEFAULT_USE_PROXY,
"테스트베드 API 호출 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
}
/**
* 토큰 발급 서버 프록시({@code /api/call-api}) 사용 여부. API 호출 프록시와 별개로 설정.
* {@code false} 이면 브라우저에서 토큰 URL 직접 호출(CORS 허용 필요). 기본 true.
* (mock 모드는 포탈 자체 mock 토큰이라 프록시 유지가 자연스럽고, GW 직접호출로 있음.)
*/
public boolean tokenUseProxy() {
return parseBool(resolve(KEY_TOKEN_USE_PROXY, DEFAULT_TOKEN_USE_PROXY,
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
}
/**
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
* {@code true}/{@code Y} true, {@code false}/{@code N} false, /null {@code def}.
*/
private static boolean parseBool(String v, boolean def) {
if (v == null) {
return def;
}
String s = v.trim();
if (s.equalsIgnoreCase("true") || s.equalsIgnoreCase("Y")) {
return true;
}
if (s.equalsIgnoreCase("false") || s.equalsIgnoreCase("N")) {
return false;
}
return def;
}
/** 테스트베드 프록시 연결/응답 타임아웃(ms). 미설정/파싱 실패 시 10초. (단위 저장: 초) */
public int timeoutMillis() {
try {
int sec = Integer.parseInt(resolve(KEY_TIMEOUT_SEC, DEFAULT_TIMEOUT_SEC,
"테스트베드 프록시 연결/응답 타임아웃(초)"));
return sec > 0 ? sec * 1000 : TIMEOUT_FALLBACK_MS;
} catch (Exception e) {
return TIMEOUT_FALLBACK_MS;
}
}
public DjbGatewayMode resolveGatewayMode() {
return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY;
}
/**
* OAuth 토큰 발급 URL.
* PortalMock {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
* GATEWAY {@code baseUrl + tokenPath}.
*/
public String resolveTokenUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
}
return stripTrailingSlash(baseUrl()) + tokenPath();
}
/**
* GW 응답유형(spec responseType=gw)에서 사용할 API 호출 base URL.
* PortalMock 요청 포탈 origin(mock 필터가 프록시), GATEWAY {@code baseUrl}.
* (별도 {@code swagger.gw.address} 프로퍼티를 두지 않고 값을 단일 기준으로 사용한다.)
*/
public String resolveApiBaseUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin);
}
return stripTrailingSlash(baseUrl());
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return "";
}
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
}
@@ -0,0 +1,51 @@
package com.eactive.apim.portal.djb.testbed.controller;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 테스트베드 인증 컨텍스트/시크릿 REST.
* 컨트롤러 진입은 인증 없이 허용하되(비대상자도 context 받아야 ),
* 실제 인증/역할/소유권 검증은 {@link DjbTestbedAuthService} 수행한다.
*/
@RestController
@RequestMapping("/djb/testbed/auth")
@RequiredArgsConstructor
public class DjbTestbedAuthController {
private final DjbTestbedAuthService authService;
@GetMapping("/context")
public ResponseEntity<DjbTestbedContextDto> context(HttpServletRequest request) {
return ResponseEntity.ok(authService.buildContext(resolveOrigin(request)));
}
@GetMapping("/credentials/{clientId}/secret")
public ResponseEntity<DjbCredentialSecretDto> credentialSecret(@PathVariable String clientId,
@RequestParam String apiId) {
DjbCredentialSecretDto secret = authService.getCredentialSecret(clientId, apiId);
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.header(HttpHeaders.PRAGMA, "no-cache")
.body(secret);
}
/** scheme://host[:port] (기본 포트는 생략). PortalMock 토큰 URL 구성에 사용. */
private String resolveOrigin(HttpServletRequest request) {
String scheme = request.getScheme();
String host = request.getServerName();
int port = request.getServerPort();
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
return scheme + "://" + host + (defaultPort ? "" : ":" + port);
}
}
@@ -0,0 +1,75 @@
package com.eactive.apim.portal.djb.testbed.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* testbed spec(swagger.json/yaml) AUTHTYPE 기반 securityScheme 주입하고,
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL}) API SPEC 설정(responseType)
* 따른 실주소로 치환해 반환한다.
* {@code default-token-api-spec} 클래스패스 기본 spec 그대로 반환(auth enrich 대상 ).
*/
@RestController
@RequestMapping("/djb/testbed/apis")
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecController {
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedAuthService authService;
private final DjbSwaggerSpecEnricher enricher;
private final DjbTestbedSpecServerRewriter serverRewriter;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@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);
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);
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 {
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);
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return serverRewriter.rewriteServer(enriched, spec.get(), request);
}
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.djb.testbed.dto;
import lombok.Builder;
import lombok.Data;
/**
* 테스트베드 셀렉트에 노출할 (PTL_CREDENTIAL) 1건. 시크릿은 포함하지 않는다.
*/
@Data
@Builder
public class DjbCredentialOptionDto {
/** PTL_CREDENTIAL.clientid */
private String clientId;
/** 사용자에게 보여줄 앱명(PTL_CREDENTIAL.clientname) */
private String clientName;
/** "0": 차단, "1": 정상 */
private String appStatus;
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.testbed.dto;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import lombok.Builder;
import lombok.Data;
/**
* 선택한 앱의 시크릿 단건 응답(캐시 금지 · 로그 마스킹 대상).
* OAUTH {@code clientSecret} client_secret, API_KEY API Key 값이다.
*/
@Data
@Builder
public class DjbCredentialSecretDto {
private String clientId;
private String clientSecret;
private DjbAuthType authType;
}
@@ -0,0 +1,36 @@
package com.eactive.apim.portal.djb.testbed.dto;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.util.List;
import lombok.Builder;
import lombok.Data;
/**
* {@code GET /djb/testbed/auth/context} 응답. 시크릿은 포함하지 않는다.
*/
@Data
@Builder
public class DjbTestbedContextDto {
/** 인증 정보 제공 대상자 여부 */
private boolean eligible;
/** 비대상 사유 코드: ANONYMOUS / INDIVIDUAL_USER / NO_CREDENTIAL (대상자면 null) */
private String reason;
/** 사용자가 선택할 수 있는 앱 목록 */
private List<DjbCredentialOptionDto> credentials;
/** 게이트웨이 정보(시크릿 제외) */
private GatewayInfo gateway;
@Data
@Builder
public static class GatewayInfo {
private DjbGatewayMode mode;
private String baseUrl;
private String tokenUrl;
private String apiKeyHeader;
private String oauthTokenHeader;
}
}
@@ -0,0 +1,42 @@
package com.eactive.apim.portal.djb.testbed.enums;
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
/**
* 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다.
* <ul>
* <li>{@code oauth}/{@code oauth2}/{@code ca} {@link #OAUTH}</li>
* <li>{@code api_key}/{@code apikey} {@link #API_KEY}</li>
* <li>{@code none}/{@code no}/{@code noauth} {@link #NONE}(인증 없이 호출 선택 불필요)</li>
* <li>{@code null}(EAIMessage 미존재) {@link DjbUnsupportedAuthTypeException}</li>
* </ul>
* ({@code ca} 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} OAUTH2 취급하는 .)
*/
public enum DjbAuthType {
OAUTH,
API_KEY,
NONE;
public static DjbAuthType fromAuthtype(String authtype) {
// null(EAIMessage 미존재) 데이터 문제 종전대로 예외. "none" 인증 없는 API.
if (authtype == null) {
throw new DjbUnsupportedAuthTypeException("null");
}
String normalized = authtype.trim().toLowerCase();
switch (normalized) {
case "oauth":
case "oauth2":
case "ca":
return OAUTH;
case "api_key":
case "apikey":
return API_KEY;
case "none":
case "no":
case "noauth":
return NONE;
default:
throw new DjbUnsupportedAuthTypeException(authtype);
}
}
}
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.djb.testbed.enums;
/**
* 테스트베드 호출이 향하는 대상 모드.
* <ul>
* <li>{@code PORTAL_MOCK} {@code djb.gateway.base-url} 문자열 "PortalMock" 경우.
* {@code ApiTesterFilter} mock 토큰/샘플 응답을 반환한다.</li>
* <li>{@code GATEWAY} (실제 게이트웨이 URL). 토큰 발급 요청을 게이트웨이로 forward.</li>
* </ul>
*/
public enum DjbGatewayMode {
PORTAL_MOCK,
GATEWAY
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.testbed.exception;
/**
* TSEAIHE01.AUTHTYPE 값이 테스트베드가 지원하는 인증 체계(oauth/api_key)
* 매핑되지 않을 발생. {@link com.eactive.apim.portal.djb.testbed.advice.DjbTestbedExceptionHandler}
* 에서 400 응답으로 변환된다.
*/
public class DjbUnsupportedAuthTypeException extends RuntimeException {
private final String authtype;
public DjbUnsupportedAuthTypeException(String authtype) {
super("지원하지 않는 인증 체계: " + authtype);
this.authtype = authtype;
}
public String getAuthtype() {
return authtype;
}
}
@@ -0,0 +1,106 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.fasterxml.jackson.core.JsonProcessingException;
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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* testbed spec(JSON) 인증 체계를 주입한다.
*
* <p>현재 데이터는 <b>OpenAPI 3.0</b>(auto-gen {@code "openapi":"3.0.0"})이므로
* {@code components.securitySchemes} + 글로벌 {@code security} 주입한다.
* (원본이 Swagger 2.0 이면 {@code securityDefinitions} 폴백.)
*
* <p>게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 아니라 커스텀 헤더
* {@code X-AUTH-TOKEN: Bearer ...} 받으므로(DJERP_4000 v0.2), OAuth API_KEY 동일하게
* <b>apiKey-in-header</b> 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가
* {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer <token>"} 으로 authorize 한다.
*
* <p>원본의 {@code paths}, {@code x-*} 확장, 기타 {@code components} 트리 조작으로 보존한다
* (문자열 치환 금지).
*/
@Service
@RequiredArgsConstructor
public class DjbSwaggerSpecEnricher {
private final ObjectMapper objectMapper;
private final DjbTestbedGatewayProperty gatewayProperty;
public static final String OAUTH_SCHEME = "djbOAuth";
public static final String API_KEY_SCHEME = "djbApiKey";
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
// 예상 형태면 원본 유지(멱등)
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
// NONE: 인증 없이 호출 글로벌 security / securityScheme 흔적을 제거해
// Swagger UI 'Authorize' 버튼이 뜨지 않게 한다. (저장 spec securitySchemes
// 남아 있으면 UI 인증 모달 버튼을 렌더하므로 정리한다.)
if (authType == DjbAuthType.NONE) {
return stripSecurity(root);
}
String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME;
ObjectNode scheme = buildApiKeyScheme(authType);
boolean swagger2 = root.has("swagger") && !root.has("openapi");
if (swagger2) {
getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme);
} else {
ObjectNode components = getOrCreateObject(root, "components");
getOrCreateObject(components, "securitySchemes").set(schemeName, scheme);
}
// 글로벌 security 요구사항 (apiKey 스킴은 스코프 배열)
ArrayNode security = objectMapper.createArrayNode();
ObjectNode requirement = objectMapper.createObjectNode();
requirement.set(schemeName, objectMapper.createArrayNode());
security.add(requirement);
root.set("security", security);
return objectMapper.writeValueAsString(root);
}
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
String headerName = (authType == DjbAuthType.OAUTH)
? gatewayProperty.oauthHeader()
: gatewayProperty.apiKeyHeader();
ObjectNode scheme = objectMapper.createObjectNode();
scheme.put("type", "apiKey");
scheme.put("in", "header");
scheme.put("name", headerName);
return scheme;
}
/** NONE 용: 글로벌 {@code security} / {@code components.securitySchemes} / {@code securityDefinitions} 제거. */
private String stripSecurity(ObjectNode root) throws JsonProcessingException {
root.remove("security");
root.remove("securityDefinitions"); // swagger 2.0
JsonNode components = root.get("components");
if (components != null && components.isObject()) {
((ObjectNode) components).remove("securitySchemes");
}
return objectMapper.writeValueAsString(root);
}
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
JsonNode node = parent.get(field);
if (node != null && node.isObject()) {
return (ObjectNode) node;
}
ObjectNode created = objectMapper.createObjectNode();
parent.set(field, created);
return created;
}
}
@@ -0,0 +1,133 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialOptionDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto.GatewayInfo;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
/**
* 테스트베드 인증 컨텍스트 구성 · 시크릿 조회 · authtype 매핑.
* 인증/역할/소유권 검증은 서비스에서 수행한다({@code SecurityFilterChain} URL 인가 블록이 없음).
*/
@Service
@RequiredArgsConstructor
public class DjbTestbedAuthService {
private final CredentialRepository credentialRepository;
private final EAIMessageRepository eaiMessageRepository;
private final DjbTestbedGatewayProperty gatewayProperty;
private static final String APP_STATUS_ACTIVE = "1";
/**
* 비대상 사유: {@code ANONYMOUS}(비로그인) / {@code INDIVIDUAL_USER}(ROLE_USER) /
* {@code NO_CREDENTIAL}(appstatus='1' 0건). 하나라도 해당하면 {@code eligible=false}.
*/
public DjbTestbedContextDto buildContext(String portalOrigin) {
GatewayInfo gateway = buildGatewayInfo(portalOrigin);
if (!SecurityUtil.isAuthenticated()) {
return notEligible("ANONYMOUS", gateway);
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user.getRoleCode() == RoleCode.ROLE_USER) {
return notEligible("INDIVIDUAL_USER", gateway);
}
String orgId = SecurityUtil.getUserOrg().getId();
List<DjbCredentialOptionDto> options = credentialRepository.findAllByOrgid(orgId).stream()
.filter(c -> APP_STATUS_ACTIVE.equals(c.getAppstatus()))
.map(this::toOption)
.collect(Collectors.toList());
if (options.isEmpty()) {
return notEligible("NO_CREDENTIAL", gateway);
}
return DjbTestbedContextDto.builder()
.eligible(true)
.reason(null)
.credentials(options)
.gateway(gateway)
.build();
}
/**
* 선택 앱의 시크릿 단건. 로그인 + -ROLE_USER + 본인 소유(orgId 일치) 검증 반환.
* 소유 불일치/비대상은 {@link AccessDeniedException}( 403).
*/
public DjbCredentialSecretDto getCredentialSecret(String clientId, String apiId) {
if (!SecurityUtil.isAuthenticated()) {
throw new AccessDeniedException("로그인이 필요합니다.");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user.getRoleCode() == RoleCode.ROLE_USER) {
throw new AccessDeniedException("개인사용자는 이용할 수 없습니다.");
}
String orgId = SecurityUtil.getUserOrg().getId();
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new AccessDeniedException("본인 소유의 앱이 아닙니다."));
DjbAuthType authType = resolveAuthType(apiId);
return DjbCredentialSecretDto.builder()
.clientId(credential.getClientid())
.clientSecret(credential.getClientsecret())
.authType(authType)
.build();
}
/**
* apiId(=TSEAIHE01.eaisvcname, admin 입력 컨벤션) authtype 조회 매핑.
* 미매핑/미존재는 {@code DjbUnsupportedAuthTypeException}( 400).
*/
public DjbAuthType resolveAuthType(String apiId) {
String authtype = eaiMessageRepository.findById(apiId)
.map(EAIMessageEntity::getAuthtype)
.orElse(null);
return DjbAuthType.fromAuthtype(authtype);
}
private DjbCredentialOptionDto toOption(Credential c) {
return DjbCredentialOptionDto.builder()
.clientId(c.getClientid())
.clientName(c.getClientname())
.appStatus(c.getAppstatus())
.build();
}
private GatewayInfo buildGatewayInfo(String portalOrigin) {
return GatewayInfo.builder()
.mode(gatewayProperty.resolveGatewayMode())
.baseUrl(gatewayProperty.baseUrl())
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
.apiKeyHeader(gatewayProperty.apiKeyHeader())
.oauthTokenHeader(gatewayProperty.oauthHeader())
.build();
}
private DjbTestbedContextDto notEligible(String reason, GatewayInfo gateway) {
return DjbTestbedContextDto.builder()
.eligible(false)
.reason(reason)
.credentials(Collections.emptyList())
.gateway(gateway)
.build();
}
}
@@ -0,0 +1,110 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
/**
* testbed spec(JSON) baking 서버 sentinel({@value #SERVER_SENTINEL})
* API SPEC 설정(responseType) 따른 실주소로 치환한다.
*
* <ul>
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
* </ul>
*
* <p>spec 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
* admin(app.js buildOpenApiSpec) env별 실주소를 저장시 굳히지 않고 sentinel path 앞에 baking 하며,
* 실제 치환은 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec YAML 변환 제공한다.
*
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecServerRewriter {
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
public static final String SERVER_SENTINEL = "http://swagger-server-url";
private final DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper;
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(resolveBase(spec, request));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
public String toYaml(String specJson) {
try {
Object tree = objectMapper.readValue(specJson, Object.class);
DumperOptions opts = new DumperOptions();
opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
opts.setPrettyFlow(true);
return new Yaml(opts).dump(tree);
} catch (Exception e) {
log.error("spec YAML 변환 실패, JSON 원본 반환", e);
return specJson;
}
}
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
? "sample" : spec.getResponseType().trim();
if ("gw".equalsIgnoreCase(rt)) {
return gatewayProperty.resolveApiBaseUrl(originOf(request));
}
if ("mock".equalsIgnoreCase(rt)) {
String mock = (spec == null) ? null : spec.getMockUrl();
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
}
// sample(기본): 포탈 origin
return originOf(request);
}
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
public static String originOf(HttpServletRequest request) {
if (request == null) {
return "";
}
String scheme = firstNonBlank(request.getHeader("X-Forwarded-Proto"), request.getScheme());
String host = firstNonBlank(request.getHeader("X-Forwarded-Host"), request.getHeader("Host"));
if (StringUtils.hasText(host)) {
return scheme + "://" + host.trim();
}
int port = request.getServerPort();
String hostPort = request.getServerName() + ((port == 80 || port == 443) ? "" : ":" + port);
return scheme + "://" + hostPort;
}
private static String firstNonBlank(String a, String b) {
return StringUtils.hasText(a) ? a.trim() : b;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return null;
}
String t = s.trim();
while (t.endsWith("/")) {
t = t.substring(0, t.length() - 1);
}
return t;
}
}
@@ -1,54 +0,0 @@
package com.eactive.apim.portal.djb.community.qna.comment.event;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageEventHandler;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.template.service.MessageSendEvent;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class InquiryCommentCreatedAdminEvent implements MessageEventHandler {
public static final MessageCode KEY = MessageCode.INQUIRY_COMMENT_CREATED_ADMIN;
@Override
public MessageCode getKey() {
return KEY;
}
@Override
public boolean allowAdditionalRecipients() {
return true;
}
@Override
public String getDisplayName() {
return "Q&A 댓글 등록 알림";
}
@Override
public String getDescription() {
return "<div>사용 가능 변수: </div><br/>"
+ "<div> - inquiryId: 문의 ID </div><br/>"
+ "<div> - inquirySubject: 문의 제목 </div><br/>"
+ "<div> - commentContent: 댓글 본문 </div><br/>"
+ "<div> - writerName: 작성자 이름 </div><br/>";
}
@Override
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
Map<String, String> requestParams = new HashMap<>();
requestParams.put("inquiryId", toStr(params.get("inquiryId")));
requestParams.put("inquirySubject", toStr(params.get("inquirySubject")));
requestParams.put("commentContent", toStr(params.get("commentContent")));
requestParams.put("writerName", toStr(params.get("writerName")));
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
private String toStr(Object value) {
return value == null ? "" : value.toString();
}
}
@@ -1,9 +0,0 @@
package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
@EMSDataSource
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
}
+2 -2
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: dev
@@ -39,7 +39,7 @@ gateway:
portal:
auth-virtual-code: 654321
# auth-virtual-code: 654321
test-auth-notice-enabled: true
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
+11 -2
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: prod
@@ -11,6 +11,9 @@ spring:
cachecontrol:
max-age: 86400
public: true
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
jpa:
properties:
hibernate:
@@ -26,4 +29,10 @@ spring:
cache: false
portal:
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
app:
resource-versioning:
enabled: true
resource-caching:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
web:
resources:
cache:
+33 -4
View File
@@ -2,7 +2,9 @@ server:
servlet:
context-path: /
session:
timeout: 15m
timeout: 10m
cookie:
name: PORTAL_JSESSIONID
encoding:
charset: UTF-8
port: '39130'
@@ -33,12 +35,17 @@ spring:
max-age: 0
must-revalidate: true
no-cache: true
# 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
# @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
jta:
enabled: true
enabled: false
atomikos:
properties:
log-base-dir: /logs/atomikos
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
log-base-dir: /dev/null
log-base-name: adp_tx
thymeleaf:
@@ -49,6 +56,19 @@ spring:
cache: false
main:
allow-bean-definition-overriding: true
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
app:
resource-versioning:
enabled: false
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
security:
basic:
enabled: false
@@ -235,12 +255,18 @@ page:
corp:
name: "법인회원가입"
path: "/signup/portalOrg"
verification_email:
name: "이메일 인증"
path: "/signup/verification-email"
decision:
name: "법인회원 초대"
path: "/signup/decision_process"
agreements:
name: 약관
path: "#"
children:
terms:
name: "이용약관 및 개인정보처리방침"
name: "이용약관"
path: "/agreements/terms"
about:
name: 안내
@@ -310,6 +336,9 @@ page:
mypage:
name: "내 정보 관리"
path: "/mypage"
verification_email:
name: "이메일 인증"
path: "/mypage/verification-email"
user_list:
name: "이용자 관리"
path: "/users"

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