Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5585df9133 | |||
| 2c63fa1557 | |||
| d71af34950 | |||
| fbc145d145 | |||
| f36b1a6478 | |||
| 8f03209f8a | |||
| 8743592fde | |||
| a0685c8689 | |||
| c0ae60f738 | |||
| b45a7a162a | |||
| 07fbe1ba54 | |||
| 4916bdf4af | |||
| 35621b5174 | |||
| b97b0fcf7b | |||
| 6cb98d36bb | |||
| fd229fca43 | |||
| 5e8d08f1af | |||
| e7945233fe | |||
| 815e2bdd04 | |||
| 0629e842a3 | |||
| 6fa2167378 | |||
| 84e873a08b | |||
| 6c231c4189 | |||
| e3b5a44270 | |||
| 829630ae5c | |||
| 2a9694cd85 | |||
| 456b635a32 | |||
| faaf9e8cd6 | |||
| 4749613947 | |||
| 5125dab897 | |||
| 02eb81af67 | |||
| c02a5ff047 | |||
| 4181d114c1 | |||
| daff07c006 | |||
| 1f51c9b21e | |||
| 929980c089 | |||
| d4d5d66727 | |||
| d3e6249a41 | |||
| 68f8c07fc0 | |||
| 87369ce7f7 | |||
| a2f6151a08 | |||
| b29882c0b2 | |||
| 80740a9543 | |||
| cdd68f48f0 | |||
| d2bc61dc31 | |||
| 73deb7f02e | |||
| fedd3e4c50 | |||
| 6c13b6bc63 | |||
| b54fedc280 | |||
| a4316545dc | |||
| b5ffa69eba | |||
| 2106142d93 | |||
| 0f58f043a8 | |||
| 61be6bf108 | |||
| 96d6dd3df1 | |||
| f9b53810d9 | |||
| 41be827b81 | |||
| 78898ab5a8 | |||
| 19c17f4e91 | |||
| c2d84bb024 | |||
| 6434d48b05 | |||
| d5f0f3aab4 | |||
| dfc859c9cf | |||
| 24c669b0be | |||
| 3eedc522e2 | |||
| a361cc3dec | |||
| 1ac9fa5407 | |||
| 9981459691 | |||
| a2878f9cee | |||
| 1f10dda993 | |||
| a6807a37c2 | |||
| 6c10ae12c7 | |||
| 01c4a80182 | |||
| a5611cf775 | |||
| c733e6b200 | |||
| a2813f391c | |||
| f221c20ece | |||
| 4a3b5b43f3 | |||
| 8de0d94063 | |||
| 57d4efb4eb | |||
| c865359819 | |||
| 41391c8df0 | |||
| 9f4874fe44 | |||
| 43d915342e | |||
| ba00b80bfe | |||
| 87763ba325 | |||
| 5b7a4a108d | |||
| 0f581cae23 | |||
| 923bf56e21 | |||
| 9b9bcb5be4 | |||
| bdb249b74b | |||
| 1cb9606602 | |||
| 93f7c210bc | |||
| bb79b55f08 | |||
| 227d4d8abd | |||
| 23bd0c1207 | |||
| 28a9ff1a7f | |||
| 4395757d98 | |||
| 778d579ada | |||
| 3e403788e5 | |||
| 6c5ba6765d | |||
| a97378b84d | |||
| 9c01db9d00 | |||
| 08bd705dec | |||
| 99f765507b | |||
| 7d6aabe122 | |||
| f08da86eb3 | |||
| 045f354e5c | |||
| 4f577c8c14 | |||
| 2a2505d5f7 | |||
| cad574324e | |||
| 4b4ef4196e | |||
| 5402a5354a | |||
| c5b23c3873 | |||
| cc4c1a6b53 | |||
| 83d267faed | |||
| b02e598d62 | |||
| 551a2da717 | |||
| 1c57d348e6 | |||
| 4f9fbefbcc | |||
| c403118289 | |||
| a91b56ed11 | |||
| 8d232734b1 | |||
| 06c18e437b | |||
| 97b9d2161a | |||
| 0c3b63a5b1 | |||
| 582a3e0f69 | |||
| c1ae12e1ca | |||
| 77aea79363 | |||
| aa5e769148 | |||
| ee8e9e3529 | |||
| 82948a13ce | |||
| f79e528fec | |||
| 6f776ed72a | |||
| e242d3ed06 | |||
| d7abe2c2e5 | |||
| 84d92fced9 | |||
| 53468c3b83 | |||
| 198f6fc82a | |||
| 21c500610c | |||
| 687d4798d4 | |||
| 5adfd42751 | |||
| 6694715195 | |||
| fc96c13df2 | |||
| 7265ad6589 | |||
| 02f9d41334 | |||
| fe4b176512 | |||
| 17c27e4398 | |||
| 10ff5d6f2f | |||
| f671f491b7 | |||
| 3d68589ae7 | |||
| 7268aef211 | |||
| 85b2f01ce0 | |||
| 0f255d7337 | |||
| a8323e78f5 | |||
| fa8fce4f2d | |||
| 4e7044b9fe | |||
| bddd7e3f85 | |||
| 2c29c8a466 | |||
| d2052d0e16 | |||
| 9c4f3cfb04 | |||
| 71f4cb6f79 | |||
| 171feee9ea | |||
| 3f664bcd5c | |||
| c77c0a2f91 | |||
| 4482f6f807 | |||
| 34f5a89f78 | |||
| 9238584cb9 | |||
| 23235bb854 | |||
| 2d67647c8f | |||
| 4824149ba9 | |||
| 4927c301f3 | |||
| d9830fb47d | |||
| 6a1fade68d | |||
| 357d609552 | |||
| cce9753e36 | |||
| 06fdcb8bb6 | |||
| efb88caab5 | |||
| fb28a31da1 | |||
| b38ca6fe0b | |||
| 86336d52f0 | |||
| 53ca4a2a26 | |||
| 36a59b2b64 | |||
| cdf57c130c | |||
| 7e123c088f | |||
| daafbb596a | |||
| 0ffee7a101 | |||
| b47f76a783 | |||
| f591c5c81d | |||
| a0ea62f8cc | |||
| 157c66ca9d | |||
| ba5b2e61a5 | |||
| 5deda2a004 |
@@ -0,0 +1,180 @@
|
|||||||
|
name: eapim-portal CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- stage
|
||||||
|
- feats/ci-test
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, linux, eapim-portal]
|
||||||
|
env:
|
||||||
|
JAVA_HOME: /apps/opts/jdk8
|
||||||
|
JAVA_HOME_TOMCAT: /apps/opts/jdk17
|
||||||
|
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"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: eapim-portal
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout eapim-portal (this branch)
|
||||||
|
# ref 미지정 — 트리거된 브랜치/commit(github.sha) 그대로 빌드.
|
||||||
|
uses: http://172.30.1.50:3000/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: eapim-portal
|
||||||
|
|
||||||
|
- name: Checkout elink-portal-common (master, dependency)
|
||||||
|
# 의존 모듈은 항상 master 고정.
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
rm -rf elink-portal-common
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-portal-common.git \
|
||||||
|
elink-portal-common
|
||||||
|
|
||||||
|
- name: Checkout elink-online-core-jpa (master, dependency)
|
||||||
|
# 의존 모듈은 항상 master 고정.
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
mkdir -p eapim-online
|
||||||
|
rm -rf eapim-online/elink-online-core-jpa
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-online-core-jpa.git \
|
||||||
|
eapim-online/elink-online-core-jpa
|
||||||
|
|
||||||
|
- name: Verify toolchain
|
||||||
|
run: |
|
||||||
|
java -version
|
||||||
|
gradle --version
|
||||||
|
|
||||||
|
- name: Gradle build (no tests)
|
||||||
|
run: gradle clean build -x test --no-daemon
|
||||||
|
|
||||||
|
- name: Upload WAR artifacts
|
||||||
|
uses: http://172.30.1.50:3000/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: eapim-portal-wars-${{ github.sha }}
|
||||||
|
path: |
|
||||||
|
eapim-portal/build/libs/eapim-portal.war
|
||||||
|
eapim-portal/build/libs/eapim-portal-boot.war
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Publish WAR to Gitea Generic Package
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
GITEA_URL: http://172.30.1.50:3000
|
||||||
|
PKG_OWNER: djb-eapim
|
||||||
|
PKG_NAME: eapim-portal
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
VERSION="$(TZ=Asia/Seoul date +%Y-%m-%d-%H%M)-${SHORT_SHA}"
|
||||||
|
BASE="$GITEA_URL/api/packages/$PKG_OWNER/generic/$PKG_NAME/$VERSION"
|
||||||
|
|
||||||
|
echo "package version: $VERSION"
|
||||||
|
echo "endpoint base : $BASE"
|
||||||
|
|
||||||
|
for WAR in eapim-portal.war eapim-portal-boot.war; do
|
||||||
|
SRC="eapim-portal/build/libs/$WAR"
|
||||||
|
if [ ! -f "$SRC" ]; then
|
||||||
|
echo "::error::missing artifact: $SRC"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
HTTP=$(curl -sS -o "/tmp/pkg-upload-$WAR.log" -w '%{http_code}' \
|
||||||
|
--user "oauth2:${{ secrets.CI_TOKEN }}" \
|
||||||
|
--upload-file "$SRC" \
|
||||||
|
"$BASE/$WAR")
|
||||||
|
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
|
||||||
|
echo "::error::upload failed for $WAR (HTTP $HTTP)"
|
||||||
|
cat "/tmp/pkg-upload-$WAR.log" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " uploaded: $WAR (HTTP $HTTP)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "published: $GITEA_URL/$PKG_OWNER/-/packages/generic/$PKG_NAME/$VERSION"
|
||||||
|
|
||||||
|
- name: Stop tomcat (pre-clean)
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
# 1) systemd 관리 인스턴스 정지 (이번 워크플로가 띄운 것)
|
||||||
|
systemctl --user stop eapim-portal 2>/dev/null || true
|
||||||
|
|
||||||
|
# 2) systemd 외부 detached 인스턴스 (eapim-boot-start.sh 등) 정리
|
||||||
|
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"
|
||||||
|
|
||||||
|
- name: Deploy war as ROOT
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
DEPLOY_DIR="$CATALINA_BASE/webapps"
|
||||||
|
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
|
||||||
|
cp eapim-portal/build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
|
||||||
|
ls -la "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
- name: Start tomcat and readiness probe (timeout 120s)
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
|
||||||
|
BEFORE=0
|
||||||
|
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
|
||||||
|
|
||||||
|
# systemd 가 Tomcat 을 새 cgroup 으로 띄움 — runner step 종료와 무관하게 살아남음
|
||||||
|
systemctl --user start eapim-portal
|
||||||
|
|
||||||
|
DEADLINE=$(($(date +%s) + 120))
|
||||||
|
LIVE_OK=0
|
||||||
|
STATUS=000
|
||||||
|
BODY=""
|
||||||
|
while [ $(date +%s) -lt $DEADLINE ]; do
|
||||||
|
# Stage 1: liveness — servlet 응답 가능?
|
||||||
|
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
|
||||||
|
|
||||||
|
# Stage 2: readiness — DB(EMS, Gateway)까지 검증
|
||||||
|
if [ "$LIVE_OK" = "1" ]; then
|
||||||
|
BODY=$(curl -sS -o /tmp/ready-body.json -w '%{http_code}' --max-time 3 \
|
||||||
|
http://localhost:$DEPLOY_HTTP_PORT/health/ready 2>/dev/null || echo "000")
|
||||||
|
STATUS=$BODY
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "Readiness OK (/health/ready)"
|
||||||
|
cat /tmp/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 "::error::Startup error detected in log"
|
||||||
|
tail -c +$((BEFORE+1)) "$LOG" | tail -80
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "200" ]; then
|
||||||
|
echo "::error::Readiness probe failed within 120s (last HTTP status: $STATUS)"
|
||||||
|
[ -f /tmp/ready-body.json ] && echo "--- last readiness body ---" && cat /tmp/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
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: eapim-portal E2E Test
|
||||||
|
|
||||||
|
# 수동 트리거 전용. e2e 태그가 붙은 Selenium/통합 테스트만 실행.
|
||||||
|
# 요구 인프라:
|
||||||
|
# - Chrome + libgbm/libxkbcommon-x11 (Selenium 4종)
|
||||||
|
# - Oracle JDBC 또는 testcontainers (SignupFileTest)
|
||||||
|
# 미설치 환경에서는 SessionNotCreated/DB 에러로 일부 케이스가 실패할 수 있음.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: [self-hosted, linux, eapim-portal]
|
||||||
|
env:
|
||||||
|
JAVA_HOME: /apps/opts/jdk8
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: eapim-portal
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout eapim-portal (this branch)
|
||||||
|
uses: http://172.30.1.50:3000/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: eapim-portal
|
||||||
|
|
||||||
|
- name: Checkout elink-portal-common (master, dependency)
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
rm -rf elink-portal-common
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-portal-common.git \
|
||||||
|
elink-portal-common
|
||||||
|
|
||||||
|
- name: Checkout elink-online-core-jpa (master, dependency)
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
mkdir -p eapim-online
|
||||||
|
rm -rf eapim-online/elink-online-core-jpa
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-online-core-jpa.git \
|
||||||
|
eapim-online/elink-online-core-jpa
|
||||||
|
|
||||||
|
- name: Verify toolchain
|
||||||
|
run: |
|
||||||
|
java -version
|
||||||
|
gradle --version
|
||||||
|
|
||||||
|
- name: Gradle e2e test
|
||||||
|
run: gradle test --no-daemon -PincludeE2E=true
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: http://172.30.1.50:3000/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: eapim-portal-e2e-${{ github.sha }}
|
||||||
|
path: |
|
||||||
|
eapim-portal/build/reports/tests/test/
|
||||||
|
eapim-portal/build/test-results/test/
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: eapim-portal Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
- stage
|
||||||
|
- feats/ci-test
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: [self-hosted, linux, eapim-portal]
|
||||||
|
env:
|
||||||
|
JAVA_HOME: /apps/opts/jdk8
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: eapim-portal
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout eapim-portal (this branch)
|
||||||
|
# ref 미지정 — 트리거된 브랜치/commit(github.sha) 그대로 빌드.
|
||||||
|
uses: http://172.30.1.50:3000/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: eapim-portal
|
||||||
|
|
||||||
|
- name: Checkout elink-portal-common (master, dependency)
|
||||||
|
# 의존 모듈은 항상 master 고정.
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
rm -rf elink-portal-common
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-portal-common.git \
|
||||||
|
elink-portal-common
|
||||||
|
|
||||||
|
- name: Checkout elink-online-core-jpa (master, dependency)
|
||||||
|
# 의존 모듈은 항상 master 고정.
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
mkdir -p eapim-online
|
||||||
|
rm -rf eapim-online/elink-online-core-jpa
|
||||||
|
git clone --depth=1 --branch master \
|
||||||
|
http://oauth2:${{ secrets.CI_TOKEN }}@172.30.1.50:3000/djb-eapim/elink-online-core-jpa.git \
|
||||||
|
eapim-online/elink-online-core-jpa
|
||||||
|
|
||||||
|
- name: Verify toolchain
|
||||||
|
run: |
|
||||||
|
java -version
|
||||||
|
gradle --version
|
||||||
|
|
||||||
|
- name: Gradle test
|
||||||
|
run: gradle test --no-daemon
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
# 실패해도 결과 리포트는 올려서 원인 분석 가능하게.
|
||||||
|
if: always()
|
||||||
|
uses: http://172.30.1.50:3000/actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: eapim-portal-test-${{ github.sha }}
|
||||||
|
path: |
|
||||||
|
eapim-portal/build/reports/tests/test/
|
||||||
|
eapim-portal/build/test-results/test/
|
||||||
|
if-no-files-found: warn
|
||||||
+3
-1
@@ -107,4 +107,6 @@ TODO.txt
|
|||||||
diff
|
diff
|
||||||
|
|
||||||
*rinjae*
|
*rinjae*
|
||||||
*gf63*
|
*gf63*
|
||||||
|
*obsidian*
|
||||||
|
design-backup
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**기술 스택:**
|
**기술 스택:**
|
||||||
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
|
- Spring Boot 2.7.18 with Spring MVC and Thymeleaf
|
||||||
- Java 8 (targetCompatibility: 1.8)
|
- Java 8 (sourceCompatibility/targetCompatibility: 1.8)
|
||||||
- Gradle 8.7 빌드 시스템
|
- Gradle 8.7 빌드 시스템
|
||||||
- Oracle 19c 데이터베이스 (JNDI를 통한 이중 데이터소스)
|
- Oracle 19c 데이터베이스 (JNDI를 통한 이중 데이터소스)
|
||||||
- Spring Security (커스텀 인증)
|
- Spring Security (커스텀 인증)
|
||||||
@@ -59,7 +59,7 @@ git push origin jenkins_with_weblogic
|
|||||||
|
|
||||||
### 애플리케이션 실행
|
### 애플리케이션 실행
|
||||||
- gradlew 사용 금지 - offline gradle 단독 실행
|
- gradlew 사용 금지 - offline gradle 단독 실행
|
||||||
- msi-gf63 컴퓨터에서는 /c/eactive/workspaces/shell-scripts/kjb-gradle.sh 로 gradle 실행 (jdk, gradle 등 환경 변수 세팅 후 gradle 실행하는 스크립트)
|
- JDK/Gradle 환경 변수는 저장소 루트의 `.envrc`(direnv)로 자동 설정됨 (JAVA_HOME → Zulu JDK 8, GRADLE_USER_HOME → 격리 디렉터리)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# dev 프로파일로 실행 (기본값)
|
# dev 프로파일로 실행 (기본값)
|
||||||
@@ -173,13 +173,13 @@ com.eactive.apim.portal/
|
|||||||
|
|
||||||
1. **EMS Database** (Portal/Admin)
|
1. **EMS Database** (Portal/Admin)
|
||||||
- JNDI: `jdbc/dsOBP_EMS`
|
- JNDI: `jdbc/dsOBP_EMS`
|
||||||
- Schema: `EMSADM`
|
- Schema: `EMSAPP`
|
||||||
- Entities: `com.eactive.apim.portal.apps.*`
|
- Entities: `com.eactive.apim.portal.apps.*`
|
||||||
- 목적: 포털 사용자, 조직, API 키, 승인
|
- 목적: 포털 사용자, 조직, API 키, 승인
|
||||||
|
|
||||||
2. **Gateway Database** (API Specs)
|
2. **Gateway Database** (API Specs)
|
||||||
- JNDI: `jdbc/dsOBP_AGW`
|
- JNDI: `jdbc/dsOBP_AGW`
|
||||||
- Schema: `AGWADM`
|
- Schema: `AGWAPP`
|
||||||
- Entities: `com.eactive.eai.data.entity.onl.*`
|
- Entities: `com.eactive.eai.data.entity.onl.*`
|
||||||
- 목적: API 명세, 서비스, 메시지
|
- 목적: API 명세, 서비스, 메시지
|
||||||
|
|
||||||
@@ -265,6 +265,41 @@ API 키는 승인 워크플로우와 함께 관리됩니다:
|
|||||||
8. `src/main/resources/templates/views/newfeature/`에 Thymeleaf 템플릿 추가
|
8. `src/main/resources/templates/views/newfeature/`에 Thymeleaf 템플릿 추가
|
||||||
9. `application.yml`의 `pages` 섹션에 라우트 설정 (또는 `@GetMapping`/`@PostMapping`으로 직접 매핑)
|
9. `application.yml`의 `pages` 섹션에 라우트 설정 (또는 `@GetMapping`/`@PostMapping`으로 직접 매핑)
|
||||||
|
|
||||||
|
### 메뉴/네비게이션 항목 추가하기
|
||||||
|
|
||||||
|
**중요:** 메뉴 정의가 **두 곳으로 분리**되어 있다. yml 한 곳만 고치면 헤더 드롭다운에 안 보이고, 헤더만 고치면 브레드크럼이 비어 보인다. 메뉴 추가/이름 변경/순서 변경 시 **반드시 두 파일을 같이 수정**한다.
|
||||||
|
|
||||||
|
1. **`application.yml`의 `page:` 트리** (브레드크럼·페이지 메타용)
|
||||||
|
- `PageService`가 URL → breadcrumb 매핑에 사용
|
||||||
|
- 부모 메뉴(`service`, `apis`, `community` 등) 아래 `children:`에 새 키를 추가
|
||||||
|
```yaml
|
||||||
|
page:
|
||||||
|
home:
|
||||||
|
children:
|
||||||
|
service:
|
||||||
|
children:
|
||||||
|
oauth2_guide:
|
||||||
|
name: "OAuth2 인증가이드"
|
||||||
|
path: "/service/oauth2-guide"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`templates/views/fragment/djbank/header_container.html`** (상단 글로벌 네비)
|
||||||
|
- `<nav class="header-center"><ul class="nav-menu">` 블록에 정적 `<li>`로 하드코딩되어 있다 — yml 트리를 iterate 하지 **않음**
|
||||||
|
- 부모 메뉴의 `<ul class="sub-menu">`에 `<li><a th:href="@{/...}">메뉴명</a></li>` 직접 추가
|
||||||
|
|
||||||
|
3. **라우트도 같이 등록** (`application.yml`의 `portal.pages` 매핑 섹션, 또는 `@GetMapping`)
|
||||||
|
```yaml
|
||||||
|
portal:
|
||||||
|
pages:
|
||||||
|
- path-pattern: /service/oauth2-guide
|
||||||
|
method: GET
|
||||||
|
view-name: apps/service/oauth2-guide
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **검증**: 변경 후 (a) 헤더 드롭다운에 항목이 보이는지, (b) 새 페이지 진입 시 브레드크럼에 메뉴명이 보이는지 둘 다 확인.
|
||||||
|
|
||||||
|
5. **재시작**: `application.yml` 변경은 `PortalPageHandlerMapper`가 `@PostConstruct`에서 한 번만 등록하므로 **자동 라이브 리로드 안 됨** — 수동 재시작 필요. HTML/CSS만 라이브 반영됨.
|
||||||
|
|
||||||
### MapStruct DTO 매핑
|
### MapStruct DTO 매핑
|
||||||
|
|
||||||
타입 안전한 DTO 변환을 위해 MapStruct 사용:
|
타입 안전한 DTO 변환을 위해 MapStruct 사용:
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
pipeline {
|
||||||
|
agent none
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
skipDefaultCheckout() // stage별 agent의 자동 SCM checkout 방지 (Deploy 노드는 git 접근 불필요, unstash로만 WAR 수신)
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
|
||||||
|
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Build (djb-vm)') {
|
||||||
|
agent { label 'djb-vm' }
|
||||||
|
environment {
|
||||||
|
JAVA_HOME = '/apps/opts/jdk8'
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
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 -Pprofile=weblogic' }
|
||||||
|
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 -Pprofile=weblogic' }
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cd build/libs
|
||||||
|
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||||
|
'''
|
||||||
|
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
||||||
|
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
|
||||||
|
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
|
||||||
|
stage('SBOM') {
|
||||||
|
steps {
|
||||||
|
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||||
|
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy (weblogic)') {
|
||||||
|
agent { label 'weblogic' }
|
||||||
|
environment {
|
||||||
|
JENKINS_NODE_COOKIE = 'dontKillMe' // background weblogic를 빌드 종료 시 죽이지 않게
|
||||||
|
WL_HOME = '/app/eapim/devportal'
|
||||||
|
WL_DEPLOY_DIR = '/app/eapim/devportal'
|
||||||
|
WL_WAR_NAME = 'eapim-portal.war'
|
||||||
|
WL_HTTP_PORT = '39130'
|
||||||
|
WL_NOHUP = '/logs/weblogic/domains/eapimDomain/nohup.devSvr11.out'
|
||||||
|
}
|
||||||
|
stages {
|
||||||
|
stage('Stop WebLogic') {
|
||||||
|
steps {
|
||||||
|
sh '"$WL_HOME/stopDev11.sh"' // 동기: 완전 종료까지 블록, 미기동 시에도 안전
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Deploy WAR') {
|
||||||
|
steps {
|
||||||
|
unstash 'war'
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
cp build/libs/eapim-portal.war "$WL_DEPLOY_DIR/$WL_WAR_NAME"
|
||||||
|
ls -la "$WL_DEPLOY_DIR"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Start WebLogic and readiness') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
"$WL_HOME/startDev11.sh" # nohup & 로 백그라운드 기동, 즉시 리턴
|
||||||
|
|
||||||
|
DEADLINE=$(($(date +%s) + 300))
|
||||||
|
STATUS=000
|
||||||
|
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
|
||||||
|
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
|
||||||
|
"http://localhost:$WL_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
|
||||||
|
[ "$STATUS" = "200" ] && { echo "Readiness OK (/health/ready)"; break; }
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "200" ]; then
|
||||||
|
echo "Readiness failed within 300s, last HTTP=$STATUS"
|
||||||
|
[ -f "$WL_NOHUP" ] && tail -120 "$WL_NOHUP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
node('weblogic') {
|
||||||
|
sh '''
|
||||||
|
set +e
|
||||||
|
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||||
|
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
node('weblogic') {
|
||||||
|
sh '''
|
||||||
|
set +e
|
||||||
|
payload=$(printf '{"text":"[%s #%s](%s) FAILURE"}' "$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL")
|
||||||
|
curl -sS --fail --max-time 5 -H 'Content-Type: application/json' -X POST --data "$payload" "$WEBHOOK_URL" >/dev/null || echo "Webhook failed"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
|
||||||
|
// 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
|
||||||
|
stage('SBOM') {
|
||||||
|
steps {
|
||||||
|
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||||
|
sh 'gradle sbomXlsx --no-daemon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,10 +69,17 @@ eapim-portal
|
|||||||
|
|
||||||
### 사전 요구사항
|
### 사전 요구사항
|
||||||
|
|
||||||
- JDK 8 이상
|
아래 도구 설치 및 검증 절차는 별도 문서로 분리되어 있습니다. 신규 개발자는 먼저 이 문서를 끝까지 진행하세요.
|
||||||
- Gradle 8.7
|
|
||||||
|
📄 **[개발환경 준비 사항](djb-docs/개발환경-준비-사항.md)** — JDK 8 · Gradle 8.7 · Git · Oracle 19c · Node.js & SASS · direnv · IDE 설정
|
||||||
|
|
||||||
|
요약:
|
||||||
|
|
||||||
|
- JDK 8 (Zulu 권장)
|
||||||
|
- Gradle 8.7 (gradlew 사용 금지)
|
||||||
- Git
|
- Git
|
||||||
- Oracle 19c (또는 개발 환경에 따라 접근 가능한 DB)
|
- Oracle 19c 접근 (개발: `192.168.240.177:1599`)
|
||||||
|
- Node.js 18 LTS 이상 (SCSS 컴파일용)
|
||||||
|
|
||||||
### Git Bash에서 초기 세팅
|
### Git Bash에서 초기 세팅
|
||||||
|
|
||||||
@@ -90,10 +97,7 @@ export GIT_REPO_BASE="ssh://git@192.168.240.178:18081/eapim"
|
|||||||
# export GIT_REPO_BASE="https://git.eactive.synology.me:8090/kjb-eapim"
|
# export GIT_REPO_BASE="https://git.eactive.synology.me:8090/kjb-eapim"
|
||||||
|
|
||||||
# Gradle 명령어 설정
|
# Gradle 명령어 설정
|
||||||
# 일반 환경: gradle
|
|
||||||
# msi-gf63 환경: /c/eactive/workspaces/shell-scripts/kjb-gradle.sh
|
|
||||||
export GRADLE_CMD="gradle"
|
export GRADLE_CMD="gradle"
|
||||||
# export GRADLE_CMD="/c/eactive/workspaces/shell-scripts/kjb-gradle.sh"
|
|
||||||
|
|
||||||
# ====================================
|
# ====================================
|
||||||
# 2. 작업 디렉토리 생성
|
# 2. 작업 디렉토리 생성
|
||||||
@@ -128,12 +132,6 @@ git clone ${GIT_REPO_BASE}/elink-portal-common.git
|
|||||||
cd elink-portal-common
|
cd elink-portal-common
|
||||||
git checkout master # 또는 필요한 브랜치
|
git checkout master # 또는 필요한 브랜치
|
||||||
|
|
||||||
# kjb-safedb 클론
|
|
||||||
cd $WORKSPACE_DIR
|
|
||||||
git clone ${GIT_REPO_BASE}/kjb-safedb.git
|
|
||||||
cd kjb-safedb
|
|
||||||
git checkout master # 또는 필요한 브랜치
|
|
||||||
|
|
||||||
# ====================================
|
# ====================================
|
||||||
# 6. 최종 폴더 구조 확인
|
# 6. 최종 폴더 구조 확인
|
||||||
# ====================================
|
# ====================================
|
||||||
@@ -142,7 +140,6 @@ git checkout master # 또는 필요한 브랜치
|
|||||||
# ├── eapim-online/
|
# ├── eapim-online/
|
||||||
# │ └── elink-online-core-jpa/
|
# │ └── elink-online-core-jpa/
|
||||||
# ├── elink-portal-common/
|
# ├── elink-portal-common/
|
||||||
# └── kjb-safedb/
|
|
||||||
|
|
||||||
# ====================================
|
# ====================================
|
||||||
# 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
|
# 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
|
||||||
@@ -169,10 +166,7 @@ set GIT_REPO_BASE=ssh://git@192.168.240.178:18081/eapim
|
|||||||
REM set GIT_REPO_BASE=https://git.eactive.synology.me:8090/kjb-eapim
|
REM set GIT_REPO_BASE=https://git.eactive.synology.me:8090/kjb-eapim
|
||||||
|
|
||||||
REM Gradle 명령어 설정
|
REM Gradle 명령어 설정
|
||||||
REM 일반 환경: gradle
|
|
||||||
REM msi-gf63 환경: C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
|
|
||||||
set GRADLE_CMD=gradle
|
set GRADLE_CMD=gradle
|
||||||
REM set GRADLE_CMD=C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
|
|
||||||
|
|
||||||
REM ====================================
|
REM ====================================
|
||||||
REM 2. 작업 디렉토리 생성
|
REM 2. 작업 디렉토리 생성
|
||||||
@@ -207,12 +201,6 @@ git clone %GIT_REPO_BASE%/elink-portal-common.git
|
|||||||
cd elink-portal-common
|
cd elink-portal-common
|
||||||
git checkout master
|
git checkout master
|
||||||
|
|
||||||
REM kjb-safedb 클론
|
|
||||||
cd /d "%WORKSPACE_DIR%"
|
|
||||||
git clone %GIT_REPO_BASE%/kjb-safedb.git
|
|
||||||
cd kjb-safedb
|
|
||||||
git checkout master
|
|
||||||
|
|
||||||
REM ====================================
|
REM ====================================
|
||||||
REM 6. 최종 폴더 구조 확인
|
REM 6. 최종 폴더 구조 확인
|
||||||
REM ====================================
|
REM ====================================
|
||||||
@@ -221,7 +209,6 @@ REM ├── eapim-portal\
|
|||||||
REM ├── eapim-online\
|
REM ├── eapim-online\
|
||||||
REM │ └── elink-online-core-jpa\
|
REM │ └── elink-online-core-jpa\
|
||||||
REM ├── elink-portal-common\
|
REM ├── elink-portal-common\
|
||||||
REM └── kjb-safedb\
|
|
||||||
|
|
||||||
REM ====================================
|
REM ====================================
|
||||||
REM 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
|
REM 7. eapim-portal 프로젝트로 이동 및 디펜던시 다운로드
|
||||||
@@ -253,30 +240,19 @@ REM Gradle 디펜던시 다운로드
|
|||||||
|
|
||||||
- **gradlew 사용 금지**: 반드시 시스템에 설치된 gradle을 직접 사용하거나 환경에 맞는 스크립트를 사용하세요
|
- **gradlew 사용 금지**: 반드시 시스템에 설치된 gradle을 직접 사용하거나 환경에 맞는 스크립트를 사용하세요
|
||||||
- **Gradle 명령어 변수화**: 위의 `GRADLE_CMD` 변수를 설정하면 환경별로 다른 gradle 명령어 사용 가능
|
- **Gradle 명령어 변수화**: 위의 `GRADLE_CMD` 변수를 설정하면 환경별로 다른 gradle 명령어 사용 가능
|
||||||
- **SafeDB**: 암호화 기능이 필요한 경우 kjb-safedb 모듈 설정 확인
|
|
||||||
|
|
||||||
## 빌드 및 실행
|
## 빌드 및 실행
|
||||||
|
|
||||||
### Gradle 명령어 설정
|
### Gradle 명령어 설정
|
||||||
|
|
||||||
사용 환경에 따라 적절한 gradle 명령어를 설정하세요:
|
|
||||||
|
|
||||||
**Git Bash:**
|
**Git Bash:**
|
||||||
```bash
|
```bash
|
||||||
# 일반 환경
|
|
||||||
export GRADLE_CMD="gradle"
|
export GRADLE_CMD="gradle"
|
||||||
|
|
||||||
# msi-gf63 환경
|
|
||||||
export GRADLE_CMD="/c/eactive/workspaces/shell-scripts/kjb-gradle.sh"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows CMD:**
|
**Windows CMD:**
|
||||||
```cmd
|
```cmd
|
||||||
REM 일반 환경
|
|
||||||
set GRADLE_CMD=gradle
|
set GRADLE_CMD=gradle
|
||||||
|
|
||||||
REM msi-gf63 환경
|
|
||||||
set GRADLE_CMD=C:\eactive\workspaces\shell-scripts\kjb-gradle.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 빌드 및 실행 명령어
|
### 빌드 및 실행 명령어
|
||||||
@@ -310,7 +286,6 @@ kjb-eapim/
|
|||||||
├── eapim-online/ # Online 프로젝트
|
├── eapim-online/ # Online 프로젝트
|
||||||
│ └── elink-online-core-jpa/ # Gateway 데이터 모델
|
│ └── elink-online-core-jpa/ # Gateway 데이터 모델
|
||||||
├── elink-portal-common/ # 공통 유틸리티
|
├── elink-portal-common/ # 공통 유틸리티
|
||||||
└── kjb-safedb/ # SafeDB 암호화 라이브러리
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### eapim-portal 내부 구조
|
### eapim-portal 내부 구조
|
||||||
@@ -327,7 +302,6 @@ src/main/java/com/eactive/apim/portal/
|
|||||||
이 프로젝트는 다음 모듈에 의존합니다 (settings.gradle 참조):
|
이 프로젝트는 다음 모듈에 의존합니다 (settings.gradle 참조):
|
||||||
- `eapim-online/elink-online-core-jpa`: Gateway 데이터 모델 (../eapim-online/elink-online-core-jpa)
|
- `eapim-online/elink-online-core-jpa`: Gateway 데이터 모델 (../eapim-online/elink-online-core-jpa)
|
||||||
- `elink-portal-common`: 공통 유틸리티 (../elink-portal-common)
|
- `elink-portal-common`: 공통 유틸리티 (../elink-portal-common)
|
||||||
- `kjb-safedb`: SafeDB 암호화 라이브러리 (../kjb-safedb)
|
|
||||||
|
|
||||||
## SASS / CSS 스타일 개발
|
## SASS / CSS 스타일 개발
|
||||||
|
|
||||||
@@ -339,8 +313,11 @@ src/main/java/com/eactive/apim/portal/
|
|||||||
|
|
||||||
### 설치
|
### 설치
|
||||||
|
|
||||||
|
Node.js 및 SASS 설치 절차는 [개발환경 준비 사항 § 6. Node.js & SASS 빌드 환경](djb-docs/개발환경-준비-사항.md#6-nodejs--sass-빌드-환경) 을 참고하세요.
|
||||||
|
|
||||||
|
Node.js가 이미 설치되어 있다면 프로젝트 의존성만 설치하면 됩니다:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Node.js가 설치된 상태에서
|
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -532,8 +509,6 @@ ls -lh src/main/resources/static/css/main.min.css # minified
|
|||||||
|
|
||||||
- 사용자 관리 (내부/기업)
|
- 사용자 관리 (내부/기업)
|
||||||
- API 키 관리 및 승인 워크플로우
|
- API 키 관리 및 승인 워크플로우
|
||||||
- API 카탈로그 및 문서화
|
|
||||||
- API 테스트 프록시
|
|
||||||
- 감사 추적 (Hibernate Envers)
|
- 감사 추적 (Hibernate Envers)
|
||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
@@ -543,24 +518,10 @@ ls -lh src/main/resources/static/css/main.min.css # minified
|
|||||||
- WebLogic
|
- WebLogic
|
||||||
- Tomcat (내장)
|
- Tomcat (내장)
|
||||||
|
|
||||||
### Docker
|
|
||||||
```bash
|
|
||||||
./build_docker.sh
|
|
||||||
docker run -p 8080:8080 eapim-portal:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 관련 프로젝트
|
|
||||||
|
|
||||||
프로젝트 구조 (kjb-eapim 디렉토리 기준):
|
|
||||||
- `eapim-portal/` - Portal 웹 애플리케이션 (현재 프로젝트)
|
|
||||||
- `eapim-online/` - Online 프로젝트 (elink-online-core-jpa 포함)
|
|
||||||
- `elink-portal-common/` - 공통 유틸리티 라이브러리
|
|
||||||
- `kjb-safedb/` - SafeDB 암호화 라이브러리
|
|
||||||
- `eapim-admin/` - Admin 포털 (선택사항)
|
|
||||||
- `kjb-eapim-sql/` - SQL 스크립트 (선택사항)
|
|
||||||
|
|
||||||
## 문서
|
## 문서
|
||||||
|
|
||||||
|
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
|
||||||
|
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
|
||||||
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
||||||
- **사용자 가이드**: `개발자포탈.md` (한글)
|
- **사용자 가이드**: `개발자포탈.md` (한글)
|
||||||
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
||||||
|
|||||||
@@ -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
|
|
||||||
+37
-29
@@ -3,6 +3,7 @@ plugins {
|
|||||||
id 'war'
|
id 'war'
|
||||||
id 'eclipse'
|
id 'eclipse'
|
||||||
id 'idea'
|
id 'idea'
|
||||||
|
id 'org.cyclonedx.bom' version '3.2.4'
|
||||||
id 'org.springframework.boot' version '2.7.18'
|
id 'org.springframework.boot' version '2.7.18'
|
||||||
id 'io.spring.dependency-management' version '1.1.3'
|
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 'com.eactive.elink.common:elink-common-data:4.5.5'
|
||||||
|
|
||||||
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
|
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
|
||||||
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
|
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
|
||||||
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
|
// 아직 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-online-core-jpa')
|
||||||
implementation project(':elink-portal-common')
|
implementation project(':elink-portal-common')
|
||||||
@@ -76,9 +80,10 @@ dependencies {
|
|||||||
// exclude group: 'commons-collections', module: 'commons-collections'
|
// exclude group: 'commons-collections', module: 'commons-collections'
|
||||||
}
|
}
|
||||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
|
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3'
|
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
|
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
|
||||||
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
|
||||||
|
|
||||||
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
|
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
|
||||||
|
|
||||||
@@ -128,17 +133,27 @@ bootRun {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// annotation processor 출력 디렉토리는 IntelliJ/Gradle 통합이 자동 등록함.
|
||||||
|
// 명시적으로 srcDir 추가하면 IntelliJ idea 모드에서 MapStruct가 같은 파일을
|
||||||
|
// 중복 생성하려다 javax.annotation.processing.FilerException 발생함.
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main {
|
main {
|
||||||
java {
|
java {
|
||||||
srcDir 'src/main/java'
|
srcDir 'src/main/java'
|
||||||
srcDir 'build/generated/sources/annotationProcessor/java/main'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
configurations {
|
configurations {
|
||||||
annotationProcessor
|
annotationProcessor
|
||||||
|
|
||||||
|
// WebLogic 배포 시 Tyrus WebSocket 필터(weblogic.websocket.tyrus.TyrusServletFilter)와
|
||||||
|
// 충돌 방지: WAR 에 번들된 Tomcat WsSci 가 javax.websocket.server.ServerContainer 속성을
|
||||||
|
// WsServerContainer 로 등록 → WebLogic Tyrus 필터가 TyrusServerContainer 로 캐스팅하다 실패.
|
||||||
|
// 앱은 WebSocket 미사용이므로 Tomcat WebSocket 모듈 제외.
|
||||||
|
all {
|
||||||
|
exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-websocket'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileJava {
|
compileJava {
|
||||||
@@ -148,20 +163,27 @@ compileJava {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processResources {
|
processResources {
|
||||||
exclude { details ->
|
// exclude { details ->
|
||||||
details.file.name.startsWith('application-') &&
|
// details.file.name.startsWith('application-') &&
|
||||||
details.file.name.endsWith('.yml') &&
|
// details.file.name.endsWith('.yml') &&
|
||||||
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
test {
|
test {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform {
|
||||||
|
if (project.hasProperty('includeE2E') && project.includeE2E.toBoolean()) {
|
||||||
|
includeTags 'e2e'
|
||||||
|
} else {
|
||||||
|
excludeTags 'e2e'
|
||||||
|
}
|
||||||
|
}
|
||||||
enabled = true
|
enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
bootWar {
|
bootWar {
|
||||||
archiveFileName = "eapim-portal-boot.war"
|
archiveFileName = "eapim-portal-boot.war"
|
||||||
|
mainClass = 'com.eactive.apim.portal.PortalApplication'
|
||||||
}
|
}
|
||||||
|
|
||||||
war {
|
war {
|
||||||
@@ -182,19 +204,5 @@ task printSourceSets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
|
||||||
// CryptoCli 실행 task (bcrypt 지원)
|
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
|
||||||
task cryptoCli(type: JavaExec) {
|
|
||||||
mainClass = 'com.eactive.ext.kjb.safedb.CryptoCli'
|
|
||||||
classpath = sourceSets.main.runtimeClasspath
|
|
||||||
standardInput = System.in
|
|
||||||
|
|
||||||
// SafeDB 라이브러리 경로 추가 (리눅스)
|
|
||||||
classpath += files("/safedb/JavaAPI/config")
|
|
||||||
classpath += fileTree(dir: "/safedb/JavaAPI/lib", include: ["*.jar"])
|
|
||||||
|
|
||||||
// CLI 인자 전달: ./gradlew cryptoCli --args="bcrypt hash password123"
|
|
||||||
if (project.hasProperty('args')) {
|
|
||||||
args project.args.split('\\s+')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
./gradlew bootWar
|
|
||||||
docker build -t eactive-portal .
|
|
||||||
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# 개발환경 준비 사항
|
||||||
|
|
||||||
|
EAPIM Portal 개발에 필요한 도구와 런타임을 설치하고 검증하는 절차입니다.
|
||||||
|
처음 환경을 세팅하는 신규 개발자는 이 문서를 끝까지 따라 진행한 뒤 [README.md](../README.md)의 "초기 세팅 가이드" 단계를 이어서 수행하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 한눈에 보는 요구사항
|
||||||
|
|
||||||
|
| 구분 | 도구 | 권장 버전 | 비고 |
|
||||||
|
|------|------|-----------|----------------------------------|
|
||||||
|
| 백엔드 | JDK | 1.8 (Zulu 8 권장) | `sourceCompatibility=1.8` |
|
||||||
|
| 빌드 | Gradle | 8.7 | gradlew 사용 금지 — 시스템 gradle 직접 사용 |
|
||||||
|
| 형상관리 | Git | 2.30 이상 | SSH 키 등록 권장 |
|
||||||
|
| DB | Oracle Client/접근 | 19c | 개발은 `192.168.240.177:1598` |
|
||||||
|
| 프론트엔드 빌드 | Node.js | 18 LTS 이상 | SCSS 컴파일 전용 |
|
||||||
|
| 프론트엔드 빌드 | npm | Node.js 함께 설치 | `sass` 패키지 사용 |
|
||||||
|
| (선택) 환경 변수 | direnv | 최신 | `.envrc` 자동 로드 |
|
||||||
|
| (선택) IDE | IntelliJ IDEA | 2023.x 이상 | Lombok / MapStruct 플러그인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. JDK 8 설치
|
||||||
|
|
||||||
|
Java 8 (1.8) 호환이 필요합니다. Azul Zulu JDK 8 사용을 권장합니다.
|
||||||
|
|
||||||
|
### macOS (Homebrew)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask zulu@8
|
||||||
|
# 설치 후 경로 확인
|
||||||
|
/usr/libexec/java_home -v 1.8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
1. [Azul Zulu JDK 8 다운로드](https://www.azul.com/downloads/?version=java-8-lts&package=jdk)
|
||||||
|
2. MSI 설치 후 `JAVA_HOME` 환경변수 등록
|
||||||
|
3. `PATH`에 `%JAVA_HOME%\bin` 추가
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -version # 1.8.x
|
||||||
|
javac -version # 1.8.x
|
||||||
|
echo $JAVA_HOME # macOS/Linux
|
||||||
|
echo %JAVA_HOME% # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gradle 8.7 설치
|
||||||
|
|
||||||
|
이 프로젝트는 **gradlew 사용을 금지**합니다. 시스템에 설치된 gradle 8.7을 직접 사용합니다.
|
||||||
|
|
||||||
|
### macOS (Homebrew + SDKMAN 권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SDKMAN 설치 (이미 있으면 생략)
|
||||||
|
curl -s "https://get.sdkman.io" | bash
|
||||||
|
source "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||||
|
|
||||||
|
# Gradle 8.7 설치
|
||||||
|
sdk install gradle 8.7
|
||||||
|
sdk default gradle 8.7
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
1. [Gradle 8.7 Binary-only 다운로드](https://gradle.org/releases/)
|
||||||
|
2. `C:\gradle\gradle-8.7` 경로에 압축 해제
|
||||||
|
3. `GRADLE_HOME=C:\gradle\gradle-8.7` 환경변수 등록
|
||||||
|
4. `PATH`에 `%GRADLE_HOME%\bin` 추가
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gradle -v
|
||||||
|
# Gradle 8.7
|
||||||
|
# JVM: 1.8.x
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. (권장) direnv 사용
|
||||||
|
|
||||||
|
저장소 루트의 `.envrc` 가 `JAVA_HOME`, `GRADLE_USER_HOME` 등을 자동 주입합니다.
|
||||||
|
디렉터리 진입만으로 환경이 격리되므로 다른 프로젝트와 JDK가 섞이지 않습니다.
|
||||||
|
|
||||||
|
### 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install direnv
|
||||||
|
|
||||||
|
# Windows (scoop)
|
||||||
|
scoop install direnv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 셸 훅 등록 (한 번만)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# zsh
|
||||||
|
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc
|
||||||
|
|
||||||
|
# bash
|
||||||
|
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프로젝트에서 허용
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/eapim-portal
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Git 설치
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
[Git for Windows](https://git-scm.com/download/win) 설치. Git Bash가 함께 설치되어 README의 Bash 스니펫을 그대로 사용할 수 있습니다.
|
||||||
|
|
||||||
|
### SSH 키 등록 (사내 Git 서버용)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519 -C "your.email@djbank.co.kr"
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
# 출력된 공개키를 사내 Git 서버(192.168.240.178:18081)에 등록
|
||||||
|
```
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git --version
|
||||||
|
ssh -T git@192.168.240.178 -p 18081
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Oracle 19c 접근 정보
|
||||||
|
|
||||||
|
개발 환경에서는 별도 Oracle 설치 없이 사내 개발 DB에 접속합니다.
|
||||||
|
|
||||||
|
- **Host**: `192.168.240.177`
|
||||||
|
- **Port**: `1599`
|
||||||
|
- **Schemas**: `EMSADM` (Portal), `AGWADM` (Gateway)
|
||||||
|
- 상세 계정/패스워드는 사내 비밀번호 관리자에게 문의
|
||||||
|
|
||||||
|
스테이징/운영은 JNDI를 통해 접근합니다 — `jdbc/dsOBP_EMS`, `jdbc/dsOBP_AGW`.
|
||||||
|
|
||||||
|
DB 클라이언트 도구는 자유 선택 (DBeaver, DataGrip, SQL Developer 등).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Node.js & SASS 빌드 환경
|
||||||
|
|
||||||
|
SCSS 파일을 CSS로 컴파일하기 위해 Node.js 환경이 필요합니다.
|
||||||
|
`src/main/resources/static/css/main.css` 는 SASS 컴파일 결과물이므로 **반드시 SCSS 파일을 수정한 뒤 컴파일**해야 합니다.
|
||||||
|
|
||||||
|
### 6.1 Node.js 설치 (LTS 18 이상)
|
||||||
|
|
||||||
|
#### macOS (Homebrew)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install node
|
||||||
|
```
|
||||||
|
|
||||||
|
#### macOS / Linux (nvm 권장 — 버전 관리 용이)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# nvm 설치
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
exec $SHELL -l
|
||||||
|
|
||||||
|
# Node.js LTS 설치
|
||||||
|
nvm install --lts
|
||||||
|
nvm use --lts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
1. [Node.js LTS 다운로드](https://nodejs.org/en/download) 후 MSI 설치
|
||||||
|
2. 또는 [nvm-windows](https://github.com/coreybutler/nvm-windows/releases) 사용
|
||||||
|
|
||||||
|
#### 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -v # v18.x 이상
|
||||||
|
npm -v # 9.x 이상
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 프로젝트 SASS 의존성 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/eapim-portal
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
`package.json` 의 `devDependencies.sass` (Dart Sass) 가 `node_modules/` 에 설치됩니다.
|
||||||
|
|
||||||
|
### 6.3 SASS 빌드 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1회 컴파일 (개발용, 들여쓰기 유지)
|
||||||
|
npm run sass:build
|
||||||
|
|
||||||
|
# 1회 컴파일 (배포용, minified)
|
||||||
|
npm run sass:build:minified
|
||||||
|
|
||||||
|
# 파일 변경 감지 후 자동 컴파일 (개발 중 권장)
|
||||||
|
npm run sass:watch
|
||||||
|
|
||||||
|
# 빌드 + minified 동시 생성
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 SASS 컴파일 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lh src/main/resources/static/css/main.css
|
||||||
|
ls -lh src/main/resources/static/css/main.min.css
|
||||||
|
```
|
||||||
|
|
||||||
|
파일이 갱신되었고 브라우저에서 스타일이 정상 적용되면 성공입니다.
|
||||||
|
|
||||||
|
### 6.5 SASS 작업 시 주의사항
|
||||||
|
|
||||||
|
- `src/main/resources/static/css/main.css` **직접 수정 금지** — 재컴파일 시 덮어씌워짐
|
||||||
|
- SCSS 파일은 `src/main/resources/static/sass/` 하위에서만 작성
|
||||||
|
- 새 파일 추가 시 파일명 앞에 언더스코어(`_`) 필수 (`_filename.scss`)
|
||||||
|
- `@import` 대신 `@use` 사용 (Dart Sass 3.0 호환)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. (선택) IDE 설정
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
|
||||||
|
1. **Lombok 플러그인** 설치 → `Settings → Plugins → Lombok`
|
||||||
|
2. **Annotation Processing 활성화** → `Settings → Build → Compiler → Annotation Processors → Enable annotation processing`
|
||||||
|
3. **Gradle JVM** 설정 → `Settings → Build → Build Tools → Gradle → Gradle JVM` 을 JDK 1.8 (Zulu) 로 지정
|
||||||
|
4. **MapStruct** 는 별도 플러그인 불필요 (annotation processor 만 활성화되어 있으면 됨)
|
||||||
|
|
||||||
|
### VS Code
|
||||||
|
|
||||||
|
- `Extension Pack for Java`
|
||||||
|
- `Lombok Annotations Support`
|
||||||
|
- `Sass` (Syler) — SCSS 문법 하이라이팅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 최종 체크리스트
|
||||||
|
|
||||||
|
아래 명령이 모두 정상 동작하면 개발환경 준비가 완료된 것입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -version # 1.8.x
|
||||||
|
gradle -v # 8.7 / JVM 1.8
|
||||||
|
git --version
|
||||||
|
node -v # v18 이상
|
||||||
|
npm -v
|
||||||
|
direnv version # (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
이후 [README.md](../README.md) 의 "초기 세팅 가이드" 로 이동해 프로젝트 클론과 의존 모듈 설치를 진행하세요.
|
||||||
@@ -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 $@
|
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/*
|
||||||
|
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
|
||||||
|
*
|
||||||
|
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
|
||||||
|
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
|
||||||
|
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
|
||||||
|
*
|
||||||
|
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
|
||||||
|
* classpath 나 WAR 산출물에는 포함되지 않는다.
|
||||||
|
*
|
||||||
|
* 시트: 요약 / WAR 기준
|
||||||
|
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
|
||||||
|
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
|
||||||
|
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
|
||||||
|
*/
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
|
||||||
|
allowInsecureProtocol = true
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'org.apache.poi:poi-ooxml:3.17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import groovy.json.JsonSlurper
|
||||||
|
import org.apache.poi.ss.usermodel.BorderStyle
|
||||||
|
import org.apache.poi.ss.usermodel.FillPatternType
|
||||||
|
import org.apache.poi.ss.usermodel.HorizontalAlignment
|
||||||
|
import org.apache.poi.ss.usermodel.IndexedColors
|
||||||
|
import org.apache.poi.ss.usermodel.VerticalAlignment
|
||||||
|
import org.apache.poi.ss.util.CellRangeAddress
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||||
|
|
||||||
|
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
|
||||||
|
ext.SBOM_CELL_LIMIT = 32000
|
||||||
|
|
||||||
|
task sbomXlsx {
|
||||||
|
group = 'sbom'
|
||||||
|
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
|
||||||
|
|
||||||
|
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
|
||||||
|
if (!project.hasProperty('sbomJson')) {
|
||||||
|
dependsOn 'cyclonedxBom'
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
File src = resolveBomJson(project)
|
||||||
|
File out = project.hasProperty('sbomOut')
|
||||||
|
? project.file(project.property('sbomOut'))
|
||||||
|
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
|
||||||
|
out.parentFile.mkdirs()
|
||||||
|
|
||||||
|
def bom = new JsonSlurper().parse(src, 'UTF-8')
|
||||||
|
def deploy = collectDeployJars(project)
|
||||||
|
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
|
||||||
|
|
||||||
|
def wb = new XSSFWorkbook()
|
||||||
|
def st = createStyles(wb)
|
||||||
|
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
|
||||||
|
writeWarSheet(wb, st, warRows)
|
||||||
|
|
||||||
|
out.withOutputStream { os -> wb.write(os) }
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
int unmatched = warRows.count { it.matched == 'N' }
|
||||||
|
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
|
||||||
|
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
|
||||||
|
String sbomFileName(Project p) {
|
||||||
|
def warTask = p.tasks.findByName('war')
|
||||||
|
if (warTask != null) {
|
||||||
|
String archive = warTask.archiveFileName.get()
|
||||||
|
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
|
||||||
|
}
|
||||||
|
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
|
||||||
|
return "${p.name}${ver}-sbom.xlsx"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
|
||||||
|
File resolveBomJson(Project p) {
|
||||||
|
if (p.hasProperty('sbomJson')) {
|
||||||
|
File f = p.file(p.property('sbomJson'))
|
||||||
|
if (!f.exists()) {
|
||||||
|
throw new GradleException("bom.json 없음: ${f.absolutePath}")
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
def candidates = [
|
||||||
|
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
|
||||||
|
new File(p.buildDir, 'reports/bom.json'),
|
||||||
|
]
|
||||||
|
File found = candidates.find { it.exists() }
|
||||||
|
if (found == null) {
|
||||||
|
throw new GradleException(
|
||||||
|
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
|
||||||
|
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 배포물에 packaging 되는 jar 목록.
|
||||||
|
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
|
||||||
|
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
|
||||||
|
*
|
||||||
|
* @return [label: 기준 설명, jars: 행 목록]
|
||||||
|
*/
|
||||||
|
Map collectDeployJars(Project p) {
|
||||||
|
def cfg = p.configurations.findByName('runtimeClasspath')
|
||||||
|
if (cfg == null) {
|
||||||
|
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
|
||||||
|
return [label: '(없음)', jars: []]
|
||||||
|
}
|
||||||
|
|
||||||
|
def warTask = p.tasks.findByName('war')
|
||||||
|
def files
|
||||||
|
String label
|
||||||
|
if (warTask != null) {
|
||||||
|
files = warTask.classpath.files
|
||||||
|
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
|
||||||
|
} else {
|
||||||
|
files = cfg.files
|
||||||
|
label = 'runtimeClasspath (war 태스크 없음)'
|
||||||
|
}
|
||||||
|
|
||||||
|
def coordByFile = [:]
|
||||||
|
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
|
||||||
|
def id = a.moduleVersion.id
|
||||||
|
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
|
||||||
|
}
|
||||||
|
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
|
||||||
|
def c = coordByFile[f]
|
||||||
|
[
|
||||||
|
file : f.name,
|
||||||
|
group : c?.group ?: '',
|
||||||
|
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
|
||||||
|
version: c?.version ?: '',
|
||||||
|
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
|
||||||
|
]
|
||||||
|
}.sort { it.file }
|
||||||
|
|
||||||
|
return [label: label, jars: jars]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
|
||||||
|
Map indexComponents(bom) {
|
||||||
|
String rootRef = bom.metadata?.component?.'bom-ref'
|
||||||
|
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
|
||||||
|
|
||||||
|
def index = [:]
|
||||||
|
bom.components?.each { c ->
|
||||||
|
def hashes = [:]
|
||||||
|
c.hashes?.each { h -> hashes[h.alg] = h.content }
|
||||||
|
|
||||||
|
def licenses = (c.licenses ?: []).collect { l ->
|
||||||
|
l.license?.id ?: l.license?.name ?: l.expression ?: ''
|
||||||
|
}.findAll { it }
|
||||||
|
|
||||||
|
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
|
||||||
|
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
|
||||||
|
licenses : licenses.join('; '),
|
||||||
|
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
|
||||||
|
purl : c.purl ?: '',
|
||||||
|
sha256 : hashes['SHA-256'] ?: '',
|
||||||
|
sha1 : hashes['SHA-1'] ?: '',
|
||||||
|
description: c.description ?: '',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
|
||||||
|
List joinWarRows(List warJars, Map index) {
|
||||||
|
def result = []
|
||||||
|
warJars.eachWithIndex { j, i ->
|
||||||
|
def m = j.coord ? index[j.coord] : null
|
||||||
|
result << [
|
||||||
|
no : i + 1,
|
||||||
|
file : j.file,
|
||||||
|
group : j.group,
|
||||||
|
name : j.name,
|
||||||
|
version : j.version,
|
||||||
|
direct : m?.direct ?: '',
|
||||||
|
licenses : m?.licenses ?: '',
|
||||||
|
licenseList: m?.licenseList ?: ['(미상)'],
|
||||||
|
purl : m?.purl ?: '',
|
||||||
|
sha256 : m?.sha256 ?: '',
|
||||||
|
sha1 : m?.sha1 ?: '',
|
||||||
|
matched : (m != null) ? 'Y' : 'N',
|
||||||
|
description: m?.description ?: '',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
Map createStyles(wb) {
|
||||||
|
def headFont = wb.createFont()
|
||||||
|
headFont.setBold(true)
|
||||||
|
headFont.setColor(IndexedColors.WHITE.getIndex())
|
||||||
|
|
||||||
|
def head = wb.createCellStyle()
|
||||||
|
head.setFont(headFont)
|
||||||
|
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
|
||||||
|
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
|
||||||
|
head.setAlignment(HorizontalAlignment.CENTER)
|
||||||
|
head.setVerticalAlignment(VerticalAlignment.CENTER)
|
||||||
|
head.setBorderBottom(BorderStyle.THIN)
|
||||||
|
|
||||||
|
def body = wb.createCellStyle()
|
||||||
|
body.setVerticalAlignment(VerticalAlignment.TOP)
|
||||||
|
|
||||||
|
def wrap = wb.createCellStyle()
|
||||||
|
wrap.setVerticalAlignment(VerticalAlignment.TOP)
|
||||||
|
wrap.setWrapText(true)
|
||||||
|
|
||||||
|
def labelFont = wb.createFont()
|
||||||
|
labelFont.setBold(true)
|
||||||
|
def label = wb.createCellStyle()
|
||||||
|
label.setFont(labelFont)
|
||||||
|
|
||||||
|
return [head: head, body: body, wrap: wrap, label: label]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
|
||||||
|
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
|
||||||
|
def row = sheet.createRow(0)
|
||||||
|
row.setHeightInPoints(20f)
|
||||||
|
headers.eachWithIndex { h, i ->
|
||||||
|
def cell = row.createCell(i)
|
||||||
|
cell.setCellValue(h)
|
||||||
|
cell.setCellStyle(style)
|
||||||
|
sheet.setColumnWidth(i, widths[i] * 256)
|
||||||
|
}
|
||||||
|
sheet.createFreezePane(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
def cellOf(row, int idx, value, style) {
|
||||||
|
def cell = row.createCell(idx)
|
||||||
|
String s = (value == null) ? '' : value.toString()
|
||||||
|
if (s.length() > SBOM_CELL_LIMIT) {
|
||||||
|
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
|
||||||
|
}
|
||||||
|
cell.setCellValue(s)
|
||||||
|
cell.setCellStyle(style)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
|
||||||
|
def sheet = wb.createSheet('요약')
|
||||||
|
def comp = bom.metadata?.component ?: [:]
|
||||||
|
def tool = bom.metadata?.tools?.components?.getAt(0)
|
||||||
|
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
|
||||||
|
|
||||||
|
def items = [
|
||||||
|
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
|
||||||
|
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
|
||||||
|
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
|
||||||
|
['serialNumber', bom.serialNumber ?: ''],
|
||||||
|
['생성 시각', bom.metadata?.timestamp ?: ''],
|
||||||
|
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
|
||||||
|
['원본 파일', src.absolutePath],
|
||||||
|
['배포 수록 jar', warRows.size()],
|
||||||
|
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
|
||||||
|
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
|
||||||
|
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
|
||||||
|
['라이선스 종류', licenseKinds.size()],
|
||||||
|
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
|
||||||
|
]
|
||||||
|
|
||||||
|
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
|
||||||
|
items.eachWithIndex { item, i ->
|
||||||
|
def row = sheet.createRow(i + 1)
|
||||||
|
cellOf(row, 0, item[0], st.label)
|
||||||
|
cellOf(row, 1, item[1], st.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라이선스 분포 (요약 하단)
|
||||||
|
def byLicense = [:].withDefault { 0 }
|
||||||
|
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
|
||||||
|
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
|
||||||
|
|
||||||
|
int base = items.size() + 2
|
||||||
|
def hdr = sheet.createRow(base)
|
||||||
|
cellOf(hdr, 0, '라이선스', st.head)
|
||||||
|
cellOf(hdr, 1, 'jar 수', st.head)
|
||||||
|
sorted.eachWithIndex { e, i ->
|
||||||
|
def row = sheet.createRow(base + 1 + i)
|
||||||
|
cellOf(row, 0, e.key, st.body)
|
||||||
|
cellOf(row, 1, e.value, st.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
|
||||||
|
def writeWarSheet(wb, st, List warRows) {
|
||||||
|
def sheet = wb.createSheet('WAR 기준')
|
||||||
|
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
|
||||||
|
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
|
||||||
|
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
|
||||||
|
writeHeader(sheet, st.head, headers, widths)
|
||||||
|
|
||||||
|
warRows.eachWithIndex { r, i ->
|
||||||
|
def row = sheet.createRow(i + 1)
|
||||||
|
cellOf(row, 0, r.no, st.body)
|
||||||
|
cellOf(row, 1, r.file, st.body)
|
||||||
|
cellOf(row, 2, r.group, st.body)
|
||||||
|
cellOf(row, 3, r.name, st.body)
|
||||||
|
cellOf(row, 4, r.version, st.body)
|
||||||
|
cellOf(row, 5, r.direct, st.body)
|
||||||
|
cellOf(row, 6, r.licenses, st.body)
|
||||||
|
cellOf(row, 7, r.purl, st.body)
|
||||||
|
cellOf(row, 8, r.sha256, st.body)
|
||||||
|
cellOf(row, 9, r.sha1, st.body)
|
||||||
|
cellOf(row, 10, r.matched, st.body)
|
||||||
|
cellOf(row, 11, r.description, st.wrap)
|
||||||
|
}
|
||||||
|
if (!warRows.isEmpty()) {
|
||||||
|
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
-91
@@ -1,91 +0,0 @@
|
|||||||
pipeline {
|
|
||||||
// 25.10.01 : 기본 브랜치 staging -> jenkins_with_weblogic 로 변경
|
|
||||||
|
|
||||||
agent { label 'apipod01' }
|
|
||||||
|
|
||||||
triggers {
|
|
||||||
pollSCM('H/5 * * * *')
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
// Java 환경 설정
|
|
||||||
JAVA_HOME = '/App/jenkins/jdks/jdk1.8.0_441'
|
|
||||||
PATH = "${JAVA_HOME}/bin:${env.PATH}"
|
|
||||||
|
|
||||||
// Gradle 환경 설정
|
|
||||||
GRADLE_HOME = '/App/jenkins/gradle-8.7'
|
|
||||||
GRADLE_USER_HOME = '/App/jenkins/gradle-home'
|
|
||||||
// GRADLE_OPTS = '--offline'
|
|
||||||
|
|
||||||
// Git 저장소 정보
|
|
||||||
GIT_SERVER = '192.168.240.178:18081'
|
|
||||||
GIT_CREDENTIALS_ID = 'git-ssh-credentials'
|
|
||||||
|
|
||||||
// 프로젝트 디렉토리 구조
|
|
||||||
PROJECT_HOME = '/Data/jenkins/eapim'
|
|
||||||
|
|
||||||
BRANCH = 'jenkins_with_weblogic'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('Git pull') {
|
|
||||||
steps {
|
|
||||||
echo "[portal] Git pull from branch: ${BRANCH}"
|
|
||||||
dir("${PROJECT_HOME}/eapim-portal") {
|
|
||||||
git url: "ssh://git@${GIT_SERVER}/eapim/eapim-portal", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
echo "[online-core-jpa] Git pull from branch: ${BRANCH}"
|
|
||||||
dir("${PROJECT_HOME}/eapim-online/elink-online-core-jpa") {
|
|
||||||
git url: "ssh://git@${GIT_SERVER}/eapim/elink-online-core-jpa", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
echo "[portal-common] Git pull from branch: ${BRANCH}"
|
|
||||||
dir("${PROJECT_HOME}/elink-portal-common") {
|
|
||||||
git url: "ssh://git@${GIT_SERVER}/eapim/elink-portal-common", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[kjb-safedb] Git pull from branch: ${BRANCH}"
|
|
||||||
dir("${PROJECT_HOME}/kjb-safedb") {
|
|
||||||
git url: "ssh://git@${GIT_SERVER}/eapim/kjb-safedb", branch: "${BRANCH}", credentialsId: "${GIT_CREDENTIALS_ID}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
stage('Build') {
|
|
||||||
steps {
|
|
||||||
tool name: 'Oracle-JDK-1.8', type: 'hudson.model.JDK'
|
|
||||||
// tool name: 'Gradle-8.7', type: 'hudson.tasks.Gradle'
|
|
||||||
|
|
||||||
// echo "[online-core-jpa] Build project"
|
|
||||||
// dir("${PROJECT_HOME}/elink-online-multi/elink-online-core-jpa") {
|
|
||||||
// sh '${GRADLE_HOME}/bin/gradle clean build -x test'
|
|
||||||
// }
|
|
||||||
|
|
||||||
// echo "[portal-common] Build project"
|
|
||||||
// dir("${PROJECT_HOME}/elink-portal-common") {
|
|
||||||
// sh '${GRADLE_HOME}/bin/gradle clean build -x test'
|
|
||||||
// }
|
|
||||||
|
|
||||||
echo "[portal] Build project"
|
|
||||||
dir("${PROJECT_HOME}/eapim-portal") {
|
|
||||||
sh '${GRADLE_HOME}/bin/gradle clean build -x test'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
stage('Archive WAR') {
|
|
||||||
steps {
|
|
||||||
echo "[portal] Archive WAR file"
|
|
||||||
dir("${PROJECT_HOME}/eapim-portal/build/libs") {
|
|
||||||
archiveArtifacts artifacts: '*.war', fingerprint: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 광주은행 eAPIM
|
|
||||||
|
|
||||||
## 커스텀 항목
|
|
||||||
### 포탈 Property (EMS 프로퍼티)
|
|
||||||
- `disable_features.user_email_verify' == true
|
|
||||||
이메일 인증 기능 비활성화(회원 가입시 이메일 인증은 한걸로 처리)
|
|
||||||
|
|
||||||
### 디비 접속 방법 Direct -> JNDI 로 변경
|
|
||||||
- 은행 내부 환경에선 모두 JNDI 사용하기 때문에 서버환경에서 동작하는 profile 내 application.yaml 에서 디비 접속 정보 삭제
|
|
||||||
|
|
||||||
## 서버 운영 중 긴급 로깅이 필요할 경우 JVM 파라미터 추가 하여 재기동
|
|
||||||
- DEBUG 레벨 root 로그 / debug.log 로 파일 작성
|
|
||||||
```
|
|
||||||
-Dlogging.config=classpath:logback-debug.xml
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고자료
|
|
||||||
### 개발용 파라미터
|
|
||||||
```
|
|
||||||
-Dlogging.config=classpath:logback-rinjae.xml
|
|
||||||
-Dinst.Name=devSvr98
|
|
||||||
-Duser.language=en
|
|
||||||
-Duser.country=US
|
|
||||||
-Dkjb_safedb.mode=fake
|
|
||||||
```
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# 사업자등록번호 중복 체크 구현 작업 계획서
|
|
||||||
|
|
||||||
## 1. 현황 분석
|
|
||||||
|
|
||||||
### 문제점
|
|
||||||
- 법인 등록 시 사업자등록번호(compRegNo) 중복 검증이 없음
|
|
||||||
- `PortalOrgRepository.findByCompRegNo()` 메서드는 존재하나 사용되지 않음
|
|
||||||
- 동일한 사업자등록번호로 여러 법인이 등록될 수 있는 상태
|
|
||||||
|
|
||||||
### 검증 시점
|
|
||||||
1. **실시간 검증**: 사용자가 사업자등록번호 입력 시 `/check-business-number` API 호출
|
|
||||||
2. **등록 시 검증**: 실제 법인 생성 시점 (`registerOrgFromDTOWithFile`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 수정 대상 파일
|
|
||||||
|
|
||||||
| 파일 경로 | 수정 내용 |
|
|
||||||
|-----------|-----------|
|
|
||||||
| `src/main/java/.../apps/user/service/PortalOrgService.java` | 사업자등록번호 중복 체크 메서드 추가 |
|
|
||||||
| `src/main/java/.../apps/user/facade/OrgRegisterFacadeImpl.java` | `checkBusinessNumber()` 중복 체크 로직 추가, 등록 전 중복 체크 추가 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 구현 상세
|
|
||||||
|
|
||||||
### 3.1 PortalOrgService.java
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 추가할 메서드
|
|
||||||
public boolean existsByCompRegNo(String compRegNo) {
|
|
||||||
return portalOrgRepository.findByCompRegNo(compRegNo).isPresent();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 OrgRegisterFacadeImpl.java
|
|
||||||
|
|
||||||
#### checkBusinessNumber 메서드 수정
|
|
||||||
- 형식 검증 + 중복 검증 동시 수행
|
|
||||||
- 중복 시 "이미 등록된 사업자등록번호입니다." 메시지 반환
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<ValidationResponse> checkBusinessNumber(String compRegNo) {
|
|
||||||
// 1. 형식 검증
|
|
||||||
boolean isValidFormat = validationService.isValidBusinessNumber(compRegNo);
|
|
||||||
if (!isValidFormat) {
|
|
||||||
return ResponseEntity.ok(new ValidationResponse(false, "유효하지 않은 사업자 등록번호입니다."));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 중복 검증
|
|
||||||
boolean isDuplicate = portalOrgService.existsByCompRegNo(compRegNo);
|
|
||||||
if (isDuplicate) {
|
|
||||||
return ResponseEntity.ok(new ValidationResponse(false, "이미 등록된 사업자등록번호입니다."));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok(new ValidationResponse(true, "사용 가능한 사업자 등록번호입니다."));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 법인 생성 메서드들에 중복 체크 추가
|
|
||||||
- `registerNewCorporateUser()`
|
|
||||||
- `convertExistingUserToCorporate()`
|
|
||||||
- `registerNewCorporateUserWithExistingData()`
|
|
||||||
|
|
||||||
각 메서드에서 `portalOrgService.registerOrgFromDTOWithFile()` 호출 전 중복 체크 수행
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 검증 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
[사용자 입력]
|
|
||||||
↓
|
|
||||||
[실시간 검증] /check-business-number API
|
|
||||||
├── 형식 검증 (10자리 숫자)
|
|
||||||
└── 중복 검증 (DB 조회)
|
|
||||||
↓
|
|
||||||
[등록 요청]
|
|
||||||
↓
|
|
||||||
[등록 시 검증] registerOrgFromDTOWithFile 호출 전
|
|
||||||
└── 중복 체크 (이중 방어)
|
|
||||||
↓
|
|
||||||
[법인 생성]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 기존 중복 데이터 처리
|
|
||||||
|
|
||||||
- 기존에 중복 등록된 데이터는 그대로 유지
|
|
||||||
- 신규 등록 시에만 중복 체크 적용
|
|
||||||
- 필요시 관리자 화면에서 중복 데이터 조회/정리 기능 별도 개발 가능
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 테스트 시나리오
|
|
||||||
|
|
||||||
| 시나리오 | 예상 결과 |
|
|
||||||
|----------|-----------|
|
|
||||||
| 형식이 잘못된 사업자등록번호 입력 | "유효하지 않은 사업자 등록번호입니다." |
|
|
||||||
| 이미 등록된 사업자등록번호 입력 | "이미 등록된 사업자등록번호입니다." |
|
|
||||||
| 사용 가능한 사업자등록번호 입력 | "사용 가능한 사업자 등록번호입니다." |
|
|
||||||
| 중복된 번호로 법인 등록 시도 | 등록 실패, 에러 메시지 표시 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 작업 완료
|
|
||||||
|
|
||||||
- [x] PortalOrgService에 existsByCompRegNo 메서드 추가
|
|
||||||
- [x] OrgRegisterFacadeImpl.checkBusinessNumber 수정
|
|
||||||
- [x] 법인 등록 메서드들에 중복 체크 로직 추가
|
|
||||||
- [x] 빌드 검증 완료
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# 네비게이션 바 디자인 Survey 기능 가이드
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
직원 대상 네비게이션 바 디자인 선호도 조사를 위한 UI 기능입니다.
|
|
||||||
사용자가 A, B, C 등의 버튼을 클릭하면 실시간으로 네비게이션 바 디자인이 변경됩니다.
|
|
||||||
|
|
||||||
## 기능 활성화/비활성화
|
|
||||||
|
|
||||||
### PortalProperty 설정
|
|
||||||
|
|
||||||
DB의 `ptl_property` 테이블에서 `ui.design-survey` 값을 설정합니다.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 활성화
|
|
||||||
UPDATE ptl_property
|
|
||||||
SET property_value = 'true'
|
|
||||||
WHERE property_group_name = 'Portal' AND property_name = 'ui.design-survey';
|
|
||||||
|
|
||||||
-- 비활성화
|
|
||||||
UPDATE ptl_property
|
|
||||||
SET property_value = 'false'
|
|
||||||
WHERE property_group_name = 'Portal' AND property_name = 'ui.design-survey';
|
|
||||||
```
|
|
||||||
|
|
||||||
> 최초 페이지 로드 시 속성이 자동 생성됩니다 (기본값: `false`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 디자인 옵션 추가/수정 방법
|
|
||||||
|
|
||||||
### 파일 위치
|
|
||||||
`src/main/resources/templates/views/fragment/kjbank/header_container.html`
|
|
||||||
|
|
||||||
### DESIGN_OPTIONS 배열
|
|
||||||
|
|
||||||
디자인 옵션은 JavaScript의 `DESIGN_OPTIONS` 배열에서 관리됩니다:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const DESIGN_OPTIONS = [
|
|
||||||
{
|
|
||||||
id: 'A', // 고유 식별자 (필수)
|
|
||||||
label: 'A (현재)', // 버튼에 표시될 텍스트 (필수)
|
|
||||||
isDefault: true, // 기본 선택 옵션 (선택, 하나만 true)
|
|
||||||
styles: {} // 적용할 CSS 스타일 (필수)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'B',
|
|
||||||
label: 'B',
|
|
||||||
styles: {
|
|
||||||
'.logo-text': { 'font-size': '18px' },
|
|
||||||
'.nav-link': { 'margin': '0 20px' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'C',
|
|
||||||
label: 'C',
|
|
||||||
styles: {
|
|
||||||
'.nav-link': { 'margin': '0 20px', 'font-weight': 'bold' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 새 디자인 옵션 추가 예시
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// D 옵션 추가
|
|
||||||
{
|
|
||||||
id: 'D',
|
|
||||||
label: 'D',
|
|
||||||
styles: {
|
|
||||||
'.logo-text': {
|
|
||||||
'font-size': '16px',
|
|
||||||
'color': '#333333'
|
|
||||||
},
|
|
||||||
'.nav-link': {
|
|
||||||
'padding': '10px 24px',
|
|
||||||
'font-weight': '600'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### styles 객체 구조
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
styles: {
|
|
||||||
'CSS 셀렉터': {
|
|
||||||
'CSS 속성': '값',
|
|
||||||
'CSS 속성2': '값2'
|
|
||||||
},
|
|
||||||
'다른 셀렉터': {
|
|
||||||
'CSS 속성': '값'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 자주 사용되는 CSS 셀렉터
|
|
||||||
|
|
||||||
| 셀렉터 | 설명 |
|
|
||||||
|--------|------|
|
|
||||||
| `.logo-text` | 로고 텍스트 "API Portal" |
|
|
||||||
| `.nav-link` | 네비게이션 메뉴 링크 |
|
|
||||||
| `.nav-menu` | 네비게이션 메뉴 컨테이너 |
|
|
||||||
| `.header-content` | 헤더 콘텐츠 영역 |
|
|
||||||
| `.global-header` | 전체 헤더 |
|
|
||||||
| `.logo-wrapper` | 로고 래퍼 (이미지 + 텍스트) |
|
|
||||||
|
|
||||||
## 자주 사용되는 CSS 속성
|
|
||||||
|
|
||||||
| 속성 | 예시 값 | 설명 |
|
|
||||||
|------|---------|------|
|
|
||||||
| `font-size` | `18px`, `1.2rem` | 글자 크기 |
|
|
||||||
| `font-weight` | `400`, `500`, `600`, `bold` | 글자 두께 |
|
|
||||||
| `margin` | `0 20px`, `10px 15px` | 바깥 여백 |
|
|
||||||
| `padding` | `8px 16px` | 안쪽 여백 |
|
|
||||||
| `color` | `#333333`, `#0049b4` | 글자 색상 |
|
|
||||||
| `background` | `#ffffff`, `#f5f5f5` | 배경 색상 |
|
|
||||||
| `gap` | `12px`, `20px` | flex 아이템 간격 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 사용자 선택 저장
|
|
||||||
|
|
||||||
사용자의 디자인 선택은 브라우저의 `localStorage`에 저장됩니다.
|
|
||||||
|
|
||||||
- Key: `design-survey-selection`
|
|
||||||
- Value: 선택한 디자인 ID (예: `A`, `B`, `C`)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 저장된 선택 확인 (개발자 도구 콘솔)
|
|
||||||
localStorage.getItem('design-survey-selection')
|
|
||||||
|
|
||||||
// 선택 초기화
|
|
||||||
localStorage.removeItem('design-survey-selection')
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 관련 파일
|
|
||||||
|
|
||||||
| 파일 | 역할 |
|
|
||||||
|------|------|
|
|
||||||
| `GlobalControllerAdvice.java` | `designSurveyEnabled` ModelAttribute 제공 |
|
|
||||||
| `header_container.html` | Survey 바 HTML 및 DESIGN_OPTIONS 설정 |
|
|
||||||
| `_header.scss` | Survey 바 스타일 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 주의사항
|
|
||||||
|
|
||||||
1. **디자인 옵션 ID는 고유해야 합니다** - 중복 ID 사용 시 오동작
|
|
||||||
2. **isDefault는 하나의 옵션에만 설정** - 여러 개 설정 시 첫 번째만 적용
|
|
||||||
3. **CSS 속성명은 kebab-case 사용** - `fontSize` (X) → `font-size` (O)
|
|
||||||
4. **스타일 적용 시 `!important` 자동 추가** - 기존 스타일 덮어씀
|
|
||||||
5. **Survey 기능 종료 후** - `ui.design-survey`를 `false`로 설정하여 비활성화
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 문의
|
|
||||||
|
|
||||||
기능 관련 문의는 개발팀에 연락해주세요.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
|||||||
flowchart TD
|
|
||||||
Start([법인 관리자가 사용자 초대]) --> GenerateToken[8글자 토큰 생성<br/>예: K7MNPQ2R<br/>UserInvitation 저장<br/>상태: PENDING]
|
|
||||||
|
|
||||||
GenerateToken --> SendEmail[이메일 발송<br/>- 링크 포함<br/>- 8글자 토큰 표시]
|
|
||||||
|
|
||||||
SendEmail --> UserType{사용자 유형}
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 신규 회원 플로우
|
|
||||||
%% ========================================
|
|
||||||
UserType -->|신규 회원<br/>미가입자| NewUserPath{초대 확인 방법}
|
|
||||||
|
|
||||||
NewUserPath -->|이메일 링크| EmailLink1[이메일 링크 클릭<br/>/signup/portalUser?invitation=CODE]
|
|
||||||
NewUserPath -->|일반 회원가입| DirectSignup[일반 회원가입 진행<br/>ROLE_USER로 생성]
|
|
||||||
|
|
||||||
EmailLink1 --> SignupPage1[회원가입 페이지<br/>- 이메일 자동 입력됨<br/>- 법인명 표시]
|
|
||||||
SignupPage1 --> RegisterForm[가입 정보 입력<br/>초대 수락]
|
|
||||||
RegisterForm --> RegisterComplete[회원가입 완료<br/>ROLE_CORP_USER로 생성<br/>상태: COMPLETED]
|
|
||||||
RegisterComplete --> NewUserEnd([가입 완료 안내])
|
|
||||||
|
|
||||||
DirectSignup --> Login4[로그인]
|
|
||||||
Login4 --> AfterLogin4[로그인 성공]
|
|
||||||
AfterLogin4 --> CheckInvitation
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 기존 회원 플로우
|
|
||||||
%% ========================================
|
|
||||||
UserType -->|기존 회원<br/>ROLE_USER| ExistingUserPath{초대 확인 방법}
|
|
||||||
|
|
||||||
ExistingUserPath -->|이메일 링크<br/>비로그인 상태| EmailLink2[이메일 링크 클릭<br/>/signup/decision?invitation=CODE]
|
|
||||||
ExistingUserPath -->|이메일 링크<br/>로그인 상태| EmailLinkLoggedIn[이메일 링크 클릭<br/>/signup/decision?invitation=CODE]
|
|
||||||
ExistingUserPath -->|직접 로그인| DirectLogin[로그인 페이지에서 로그인]
|
|
||||||
|
|
||||||
%% 비로그인 상태에서 이메일 링크 클릭
|
|
||||||
EmailLink2 --> DecisionPagePublic[초대 수락 페이지<br/>비로그인 상태]
|
|
||||||
DecisionPagePublic --> RedirectLogin2[로그인 유도]
|
|
||||||
RedirectLogin2 --> Login2[로그인]
|
|
||||||
Login2 --> AfterLogin2[로그인 성공]
|
|
||||||
|
|
||||||
%% 로그인 상태에서 이메일 링크 클릭 → 바로 수락 페이지
|
|
||||||
EmailLinkLoggedIn --> DecisionPageDirect[초대 수락 페이지<br/>/signup/decision_process<br/>초대 수락]
|
|
||||||
DecisionPageDirect --> UserDecision
|
|
||||||
|
|
||||||
%% 직접 로그인
|
|
||||||
DirectLogin --> Login3[로그인]
|
|
||||||
Login3 --> AfterLogin3[로그인 성공]
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 로그인 후 초대 확인 (PortalAuthenticationSuccessHandler)
|
|
||||||
%% ========================================
|
|
||||||
AfterLogin2 --> CheckInvitation[PortalAuthenticationSuccessHandler<br/>PENDING 초대 확인<br/>findFirstByInvitationEmailAndStatus]
|
|
||||||
AfterLogin3 --> CheckInvitation
|
|
||||||
|
|
||||||
CheckInvitation --> HasInvitation{PENDING 초대 존재?}
|
|
||||||
|
|
||||||
HasInvitation -->|없음| NormalHome[메인 페이지 이동]
|
|
||||||
HasInvitation -->|있음| SaveToSession[세션에 초대 정보 저장<br/>pendingInvitation=true<br/>pendingInvitationToken<br/>pendingInvitationOrgName]
|
|
||||||
|
|
||||||
SaveToSession --> RedirectHome[메인 페이지로 이동]
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 메인 페이지 팝업 (index.html)
|
|
||||||
%% ========================================
|
|
||||||
RedirectHome --> ShowPopup[팝업 표시<br/>OOO에서 초대가 도착했습니다<br/>초대를 확인하시겠습니까?]
|
|
||||||
|
|
||||||
ShowPopup --> PopupChoice{사용자 선택}
|
|
||||||
|
|
||||||
PopupChoice -->|확인| GoDecisionPage[초대 수락 페이지 이동<br/>/signup/decision?invitation=TOKEN]
|
|
||||||
PopupChoice -->|취소| StayHome[메인 페이지 유지<br/>다음 로그인 시 팝업 재표시]
|
|
||||||
|
|
||||||
StayHome --> NextLogin[다음 로그인]
|
|
||||||
NextLogin --> CheckInvitation
|
|
||||||
|
|
||||||
GoDecisionPage --> DecisionPage[초대 수락 페이지<br/>/signup/decision_process<br/>초대 수락]
|
|
||||||
|
|
||||||
DecisionPage --> UserDecision{사용자 선택}
|
|
||||||
|
|
||||||
UserDecision -->|수락| AcceptProcess[초대 수락 처리<br/>1. PortalOrg 변경<br/>2. ROLE_CORP_USER 전환<br/>3. 약관 재동의<br/>상태: COMPLETED]
|
|
||||||
UserDecision -->|거절| RejectProcess[초대 거절 처리<br/>상태: REJECTED]
|
|
||||||
|
|
||||||
AcceptProcess --> LogoutPrompt[로그아웃 후 재로그인 안내]
|
|
||||||
LogoutPrompt --> ExistingUserEnd([초대 수락 완료])
|
|
||||||
|
|
||||||
RejectProcess --> RejectEnd([초대 거절 완료<br/>다음 로그인 시 팝업 안 뜸])
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 스타일링
|
|
||||||
%% ========================================
|
|
||||||
style Start fill:#e1f5e1
|
|
||||||
style NewUserEnd fill:#c8e6c9
|
|
||||||
style ExistingUserEnd fill:#c8e6c9
|
|
||||||
style RejectEnd fill:#fff9c4
|
|
||||||
style NormalHome fill:#e3f2fd
|
|
||||||
|
|
||||||
style GenerateToken fill:#bbdefb
|
|
||||||
style AcceptProcess fill:#c5cae9
|
|
||||||
style RejectProcess fill:#ffecb3
|
|
||||||
|
|
||||||
style CheckInvitation fill:#ffe0b2
|
|
||||||
style SaveToSession fill:#ffe0b2
|
|
||||||
style ShowPopup fill:#f8bbd0
|
|
||||||
style PopupChoice fill:#f8bbd0
|
|
||||||
|
|
||||||
style StayHome fill:#e1bee7
|
|
||||||
style NextLogin fill:#e1bee7
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
flowchart TD
|
|
||||||
Start([법인 관리자가 사용자 초대 시작]) --> CheckUser{가입된 사용자인가?}
|
|
||||||
|
|
||||||
CheckUser -->|아니오| Error1[에러: 가입된 사용자만 초대 가능]
|
|
||||||
CheckUser -->|예| CheckRole{이미 법인 사용자인가?}
|
|
||||||
|
|
||||||
CheckRole -->|예| Error2[에러: 이미 기관에 등록된 사용자]
|
|
||||||
CheckRole -->|아니오 ROLE_USER| CreateInvitation[8글자 토큰 생성<br/>UserInvitation 저장<br/>상태: PENDING]
|
|
||||||
|
|
||||||
CreateInvitation --> SendEmail[이메일 발송<br/>UserInvitationEvent]
|
|
||||||
|
|
||||||
SendEmail --> UserReceive{사용자가 토큰 받음}
|
|
||||||
|
|
||||||
UserReceive -->|폐쇄망| ManualInput[8글자 토큰 직접 입력<br/>예: K7MNPQ2R]
|
|
||||||
UserReceive -->|이메일 링크| EmailLink[Base64 인코딩된<br/>링크 클릭]
|
|
||||||
|
|
||||||
ManualInput --> DecodeToken[토큰 디코딩<br/>decodeInvitationToken]
|
|
||||||
EmailLink --> DecodeToken
|
|
||||||
|
|
||||||
DecodeToken --> ValidateToken{토큰 유효성 검증}
|
|
||||||
|
|
||||||
ValidateToken -->|무효| Error3[에러: 유효하지 않은 초대]
|
|
||||||
ValidateToken -->|유효 & PENDING| ShowDecision[약관 동의 페이지<br/>/signup/decision]
|
|
||||||
|
|
||||||
ShowDecision --> UserDecision{사용자 선택}
|
|
||||||
|
|
||||||
UserDecision -->|수락| ProcessAccept[processInvitation: accept]
|
|
||||||
UserDecision -->|거절| ProcessReject[processInvitation: reject]
|
|
||||||
|
|
||||||
ProcessAccept --> UpdateUser[1. PortalOrg 변경<br/>2. ROLE_CORP_USER로 전환<br/>3. 약관 재동의 PRIVACY_COLLECT_ORG]
|
|
||||||
UpdateUser --> CompleteInvitation[Invitation 상태:<br/>COMPLETED + completeDate]
|
|
||||||
CompleteInvitation --> Success([초대 수락 완료<br/>로그아웃 후 재로그인])
|
|
||||||
|
|
||||||
ProcessReject --> RejectInvitation[Invitation 상태:<br/>REJECTED + completeDate]
|
|
||||||
RejectInvitation --> End([초대 거절 완료])
|
|
||||||
|
|
||||||
style Start fill:#e1f5e1
|
|
||||||
style Success fill:#c8e6c9
|
|
||||||
style End fill:#fff9c4
|
|
||||||
style Error1 fill:#ffcdd2
|
|
||||||
style Error2 fill:#ffcdd2
|
|
||||||
style Error3 fill:#ffcdd2
|
|
||||||
style CreateInvitation fill:#bbdefb
|
|
||||||
style UpdateUser fill:#c5cae9
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
sequenceDiagram
|
|
||||||
actor 법인관리자
|
|
||||||
participant Portal as Portal UI
|
|
||||||
participant UserManFacade
|
|
||||||
participant UserInvitationRepo as UserInvitation Repository
|
|
||||||
participant MessageService as Message Handler
|
|
||||||
participant Email as Email System
|
|
||||||
actor 초대받은사용자
|
|
||||||
participant AuthHandler as PortalAuthenticationSuccessHandler
|
|
||||||
participant IndexController
|
|
||||||
participant MainPage as 메인 페이지 (index.html)
|
|
||||||
participant UserRegisterController
|
|
||||||
participant UserRegisterFacade
|
|
||||||
participant PortalUserRepo as PortalUser Repository
|
|
||||||
participant PortalOrgRepo as PortalOrg Repository
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 1. 초대 발송
|
|
||||||
%% ========================================
|
|
||||||
법인관리자->>Portal: 사용자 이메일 입력<br/>초대 요청
|
|
||||||
Portal->>UserManFacade: sendInvitation(sender, emailAddr)
|
|
||||||
|
|
||||||
UserManFacade->>PortalUserRepo: findByEmailAddr(emailAddr)
|
|
||||||
alt 사용자가 없거나 이미 법인 사용자
|
|
||||||
UserManFacade-->>Portal: 에러 반환
|
|
||||||
Portal-->>법인관리자: 초대 불가 알림
|
|
||||||
else 일반 사용자 ROLE_USER 또는 비회원
|
|
||||||
UserManFacade->>UserManFacade: generateEightCharToken()<br/>예: K7MNPQ2R
|
|
||||||
UserManFacade->>UserInvitationRepo: save(UserInvitation)<br/>status: PENDING<br/>expiresOn: 7일 후
|
|
||||||
UserManFacade->>MessageService: publishEvent(UserInvitationEvent)
|
|
||||||
MessageService->>Email: 초대 이메일 발송<br/>(법인명, 관리자명, 토큰)
|
|
||||||
UserManFacade-->>Portal: 초대 완료
|
|
||||||
Portal-->>법인관리자: 초대 성공 알림
|
|
||||||
end
|
|
||||||
|
|
||||||
Email->>초대받은사용자: 초대 이메일 수신
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 2-A. 로그인 상태에서 이메일 링크 클릭 (바로 수락 페이지)
|
|
||||||
%% ========================================
|
|
||||||
alt 로그인 상태에서 이메일 링크 클릭
|
|
||||||
초대받은사용자->>Portal: 이메일 링크 클릭<br/>/signup/decision?invitation=TOKEN
|
|
||||||
Portal->>UserRegisterController: showDecisionPage(invitationToken)
|
|
||||||
UserRegisterController->>UserInvitationRepo: findByToken(decodedToken)
|
|
||||||
UserRegisterController-->>Portal: 약관 동의 페이지
|
|
||||||
Portal-->>초대받은사용자: 초대 수락/거절 화면<br/>(바로 표시, 팝업 없음)
|
|
||||||
Note over 초대받은사용자: 6. 초대 수락/거절 처리로 이동
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 2-B. 로그인 (기존 회원 또는 신규 가입 후)
|
|
||||||
%% ========================================
|
|
||||||
alt 기존 회원 (비로그인 상태)
|
|
||||||
초대받은사용자->>Portal: 로그인
|
|
||||||
else 비회원이 일반 회원가입 후
|
|
||||||
초대받은사용자->>Portal: 회원가입 (ROLE_USER)
|
|
||||||
초대받은사용자->>Portal: 로그인
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 3. 로그인 성공 처리 (AuthenticationSuccessHandler)
|
|
||||||
%% ========================================
|
|
||||||
Portal->>AuthHandler: onAuthenticationSuccess()
|
|
||||||
AuthHandler->>AuthHandler: 이메일 인증, 휴면계정,<br/>비밀번호 만료 체크
|
|
||||||
|
|
||||||
AuthHandler->>UserInvitationRepo: findFirstByInvitationEmailAndStatus<br/>(loginId, PENDING)
|
|
||||||
|
|
||||||
alt PENDING 초대 없음
|
|
||||||
AuthHandler->>Portal: redirect to /
|
|
||||||
Portal-->>초대받은사용자: 메인 페이지 (팝업 없음)
|
|
||||||
else PENDING 초대 있음 & 만료 안됨
|
|
||||||
AuthHandler->>PortalOrgRepo: findById(invitation.orgId)
|
|
||||||
PortalOrgRepo-->>AuthHandler: PortalOrg (기관명)
|
|
||||||
AuthHandler->>AuthHandler: session.setAttribute<br/>("pendingInvitation", true)<br/>("pendingInvitationToken", token)<br/>("pendingInvitationOrgName", orgName)
|
|
||||||
AuthHandler->>Portal: redirect to /
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 4. 메인 페이지 팝업 표시
|
|
||||||
%% ========================================
|
|
||||||
Portal->>IndexController: GET /
|
|
||||||
IndexController->>MainPage: render index.html
|
|
||||||
|
|
||||||
MainPage->>MainPage: 세션에서 pendingInvitation 확인
|
|
||||||
|
|
||||||
alt pendingInvitation == true
|
|
||||||
MainPage-->>초대받은사용자: 팝업 표시<br/>"[기관명]에서 초대가 도착했습니다.<br/>초대를 확인하시겠습니까?"
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 5. 사용자 팝업 선택
|
|
||||||
%% ========================================
|
|
||||||
alt 팝업에서 "확인" 클릭
|
|
||||||
초대받은사용자->>Portal: /signup/decision?invitation=TOKEN
|
|
||||||
Portal->>UserRegisterController: showDecisionPage(invitationToken)
|
|
||||||
UserRegisterController->>UserInvitationRepo: findByToken(decodedToken)
|
|
||||||
UserRegisterController-->>Portal: 약관 동의 페이지
|
|
||||||
Portal-->>초대받은사용자: 초대 수락/거절 화면
|
|
||||||
else 팝업에서 "취소" 클릭
|
|
||||||
Note over 초대받은사용자,MainPage: 메인 페이지 유지<br/>다음 로그인 시 팝업 재표시
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ========================================
|
|
||||||
%% 6. 초대 수락/거절 처리
|
|
||||||
%% ========================================
|
|
||||||
초대받은사용자->>Portal: 수락 or 거절 선택
|
|
||||||
Portal->>UserRegisterController: processInvitation(action)
|
|
||||||
UserRegisterController->>UserRegisterFacade: processInvitation(action, invitation)
|
|
||||||
|
|
||||||
alt 수락
|
|
||||||
UserRegisterFacade->>PortalUserRepo: findByEmailAddr()
|
|
||||||
UserRegisterFacade->>PortalUserRepo: updateUserToCorpUser()<br/>(PortalOrg 변경<br/>ROLE_CORP_USER 전환)
|
|
||||||
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: COMPLETED<br/>completeDate: now()
|
|
||||||
UserRegisterFacade-->>Portal: 수락 성공
|
|
||||||
Portal-->>초대받은사용자: 성공 메시지<br/>로그아웃 후 재로그인 안내
|
|
||||||
Note over 초대받은사용자: 다음 로그인 시<br/>PENDING 초대 없음 → 팝업 안 뜸
|
|
||||||
else 거절
|
|
||||||
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: REJECTED<br/>completeDate: now()
|
|
||||||
UserRegisterFacade-->>Portal: 거절 처리 완료
|
|
||||||
Portal-->>초대받은사용자: 거절 완료 메시지
|
|
||||||
Note over 초대받은사용자: 다음 로그인 시<br/>REJECTED 상태 → 팝업 안 뜸
|
|
||||||
end
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
stateDiagram-v2
|
|
||||||
[*] --> 초대생성: 법인관리자가 초대 시작
|
|
||||||
|
|
||||||
초대생성 --> PENDING: 토큰 생성 및 저장
|
|
||||||
|
|
||||||
PENDING --> 이메일발송: 초대 이메일 발송
|
|
||||||
|
|
||||||
state 로그인후팝업 {
|
|
||||||
이메일발송 --> 로그인: 사용자 로그인
|
|
||||||
로그인 --> 초대확인: AuthSuccessHandler
|
|
||||||
초대확인 --> 세션저장: 세션에 초대 정보 저장
|
|
||||||
세션저장 --> 팝업표시: 메인 페이지 팝업
|
|
||||||
팝업표시 --> 팝업확인: 확인 클릭
|
|
||||||
팝업표시 --> 팝업취소: 취소 클릭
|
|
||||||
팝업취소 --> 로그인: 다음 로그인 시 재표시
|
|
||||||
}
|
|
||||||
|
|
||||||
팝업확인 --> 초대수락: 수락 페이지 이동
|
|
||||||
|
|
||||||
초대수락 --> COMPLETED: 수락
|
|
||||||
초대수락 --> REJECTED: 거절
|
|
||||||
|
|
||||||
COMPLETED --> [*]: 초대 완료
|
|
||||||
REJECTED --> [*]: 초대 거절
|
|
||||||
PENDING --> CANCELED: 관리자 취소
|
|
||||||
PENDING --> EXPIRED: 만료일 경과
|
|
||||||
CANCELED --> [*]: 초대 취소
|
|
||||||
EXPIRED --> [*]: 초대 만료
|
|
||||||
|
|
||||||
note right of PENDING
|
|
||||||
PENDING 상태일 때만
|
|
||||||
로그인 시 팝업 표시
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of REJECTED
|
|
||||||
REJECTED 상태가 되면
|
|
||||||
더 이상 팝업 표시 안 함
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of COMPLETED
|
|
||||||
COMPLETED 상태가 되면
|
|
||||||
더 이상 팝업 표시 안 함
|
|
||||||
ROLE_CORP_USER로 전환됨
|
|
||||||
end note
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 574 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Generated
+84
-466
@@ -1,33 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "eapim-portal",
|
"name": "eapim-portal",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "eapim-portal",
|
"name": "eapim-portal",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
|
||||||
"playwright": "^1.57.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
|
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^1.0.3",
|
"detect-libc": "^2.0.3",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
"micromatch": "^4.0.5",
|
"node-addon-api": "^7.0.0",
|
||||||
"node-addon-api": "^7.0.0"
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
@@ -37,25 +34,25 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@parcel/watcher-android-arm64": "2.5.1",
|
"@parcel/watcher-android-arm64": "2.5.6",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||||
"@parcel/watcher-freebsd-x64": "2.5.1",
|
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||||
"@parcel/watcher-linux-arm-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||||
"@parcel/watcher-linux-arm-musl": "2.5.1",
|
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||||
"@parcel/watcher-win32-ia32": "2.5.1",
|
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||||
"@parcel/watcher-win32-x64": "2.5.1"
|
"@parcel/watcher-win32-x64": "2.5.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-android-arm64": {
|
"node_modules/@parcel/watcher-android-arm64": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||||
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
|
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -74,9 +71,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||||
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
|
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -95,9 +92,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-darwin-x64": {
|
"node_modules/@parcel/watcher-darwin-x64": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||||
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
|
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -116,9 +113,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||||
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
|
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -137,9 +134,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||||
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
|
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -158,9 +155,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||||
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
|
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -179,9 +176,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||||
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
|
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -200,9 +197,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||||
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
|
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -221,9 +218,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||||
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
|
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -242,9 +239,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||||
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
|
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -263,9 +260,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-win32-arm64": {
|
"node_modules/@parcel/watcher-win32-arm64": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||||
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
|
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -284,9 +281,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-win32-ia32": {
|
"node_modules/@parcel/watcher-win32-ia32": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||||
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
|
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -305,9 +302,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@parcel/watcher-win32-x64": {
|
"node_modules/@parcel/watcher-win32-x64": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||||
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
|
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -325,82 +322,37 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"fill-range": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.16.0"
|
"node": ">= 20.19.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "1.0.3",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"bin": {
|
|
||||||
"detect-libc": "bin/detect-libc.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fill-range": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"to-regex-range": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -429,32 +381,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-number": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/micromatch": {
|
|
||||||
"version": "4.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"braces": "^3.0.3",
|
|
||||||
"picomatch": "^2.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
@@ -464,57 +390,27 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.57.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
|
||||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.57.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.57.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
|
||||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 20.19.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -522,21 +418,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass": {
|
"node_modules/sass": {
|
||||||
"version": "1.97.1",
|
"version": "1.100.0",
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
|
||||||
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
|
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"immutable": "^5.0.2",
|
"immutable": "^5.1.5",
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"sass": "sass.js"
|
"sass": "sass.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@parcel/watcher": "^2.4.1"
|
"@parcel/watcher": "^2.4.1"
|
||||||
@@ -551,284 +447,6 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-number": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@parcel/watcher": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
|
||||||
"@parcel/watcher-android-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
|
||||||
"@parcel/watcher-freebsd-x64": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
|
||||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-ia32": "2.5.1",
|
|
||||||
"@parcel/watcher-win32-x64": "2.5.1",
|
|
||||||
"detect-libc": "^1.0.3",
|
|
||||||
"is-glob": "^4.0.3",
|
|
||||||
"micromatch": "^4.0.5",
|
|
||||||
"node-addon-api": "^7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@parcel/watcher-android-arm64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-darwin-arm64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-darwin-x64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-freebsd-x64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-linux-arm-glibc": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-linux-arm-musl": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-linux-arm64-musl": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-linux-x64-glibc": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-linux-x64-musl": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-win32-arm64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-win32-ia32": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@parcel/watcher-win32-x64": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"braces": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
|
||||||
"fill-range": "^7.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"chokidar": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"readdirp": "^4.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"detect-libc": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"fill-range": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
|
||||||
"to-regex-range": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"immutable": {
|
|
||||||
"version": "5.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
|
||||||
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"is-extglob": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"is-glob": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
|
||||||
"is-extglob": "^2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"is-number": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"micromatch": {
|
|
||||||
"version": "4.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
|
||||||
"braces": "^3.0.3",
|
|
||||||
"picomatch": "^2.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node-addon-api": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"picomatch": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"playwright": {
|
|
||||||
"version": "1.57.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
|
||||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
|
||||||
"requires": {
|
|
||||||
"fsevents": "2.3.2",
|
|
||||||
"playwright-core": "1.57.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"playwright-core": {
|
|
||||||
"version": "1.57.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
|
||||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="
|
|
||||||
},
|
|
||||||
"readdirp": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"sass": {
|
|
||||||
"version": "1.97.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.1.tgz",
|
|
||||||
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"@parcel/watcher": "^2.4.1",
|
|
||||||
"chokidar": "^4.0.0",
|
|
||||||
"immutable": "^5.0.2",
|
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"source-map-js": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"to-regex-range": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
|
||||||
"requires": {
|
|
||||||
"is-number": "^7.0.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
@@ -13,8 +13,5 @@
|
|||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"dependencies": {
|
|
||||||
"playwright": "^1.57.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 메뉴 관리 개발 가이드
|
||||||
|
|
||||||
|
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
|
||||||
|
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
|
||||||
|
|
||||||
|
## 구성 요소
|
||||||
|
|
||||||
|
| 구성 | 위치 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
|
||||||
|
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
|
||||||
|
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
|
||||||
|
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
|
||||||
|
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
|
||||||
|
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
|
||||||
|
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
|
||||||
|
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
|
||||||
|
|
||||||
|
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
|
||||||
|
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
|
||||||
|
|
||||||
|
## menu.yml 스키마
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
portal-menu:
|
||||||
|
items:
|
||||||
|
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
|
||||||
|
name: "고객지원"
|
||||||
|
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
|
||||||
|
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
|
||||||
|
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
|
||||||
|
children:
|
||||||
|
- { id: support-faq, name: "FAQ", path: /faq_list }
|
||||||
|
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
|
||||||
|
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
|
||||||
|
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
|
||||||
|
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
|
||||||
|
|
||||||
|
## 시딩 규칙 (MenuSeeder)
|
||||||
|
|
||||||
|
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
|
||||||
|
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
|
||||||
|
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
|
||||||
|
2. **배치**: `PTL_MENU_PLACEMENT` 가 **비어있을 때만** 기본 배치로 최초 시딩.
|
||||||
|
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
|
||||||
|
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
|
||||||
|
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
|
||||||
|
|
||||||
|
## 캐시와 리로드
|
||||||
|
|
||||||
|
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
|
||||||
|
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
|
||||||
|
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
|
||||||
|
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
|
||||||
|
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
|
||||||
|
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
|
||||||
|
|
||||||
|
## 새 메뉴 추가 절차
|
||||||
|
|
||||||
|
**기본 메뉴(코드 배포와 함께)**
|
||||||
|
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
|
||||||
|
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
|
||||||
|
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
|
||||||
|
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
|
||||||
|
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
|
||||||
|
|
||||||
|
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
|
||||||
|
커스텀 항목은 미배치 시 삭제된다.
|
||||||
|
|
||||||
|
## 로컬 개발 주의
|
||||||
|
|
||||||
|
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
|
||||||
|
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
|
||||||
|
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
|
||||||
|
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
|
||||||
|
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
|
||||||
|
|
||||||
|
## 역할(roles.yml) 변경
|
||||||
|
|
||||||
|
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
|
||||||
|
admin 권한 선택 체크박스 소스 전용.
|
||||||
|
- 역할 추가 시 `roles.yml` 의 `authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
|
||||||
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
|||||||
)
|
)
|
||||||
;
|
;
|
||||||
|
|
||||||
create table DVPOWN.PT_TOKEN
|
create table PT_TOKEN
|
||||||
|
(
|
||||||
(
|
(
|
||||||
TOKEN VARCHAR2(255) not null
|
TOKEN VARCHAR2(255) not null
|
||||||
primary key,
|
primary key,
|
||||||
|
|||||||
@@ -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;
|
||||||
+1
-5
@@ -4,8 +4,4 @@ include 'elink-online-core-jpa'
|
|||||||
include 'elink-portal-common'
|
include 'elink-portal-common'
|
||||||
|
|
||||||
project (':elink-online-core-jpa').projectDir = new File(settingsDir, "../eapim-online/elink-online-core-jpa")
|
project (':elink-online-core-jpa').projectDir = new File(settingsDir, "../eapim-online/elink-online-core-jpa")
|
||||||
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
|
project (':elink-portal-common').projectDir = new File(settingsDir, "../elink-portal-common")
|
||||||
|
|
||||||
|
|
||||||
//include 'kjb-safedb'
|
|
||||||
//project (':kjb-safedb').projectDir = new File(settingsDir, "../kjb-safedb")
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway 일별 처리 통계 (AGWAPP.API_STATS_DAY) — 읽기 전용.
|
||||||
|
*
|
||||||
|
* 월별 조회 시 "현재 월"은 아직 API_STATS_MONTH 에 집계되지 않으므로(월 집계 잡은 익월 1일 실행)
|
||||||
|
* 현재 월분은 이 DAY 테이블에서 조회한다.
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "API_STATS_DAY")
|
||||||
|
@IdClass(ApiStatsDayId.class)
|
||||||
|
public class ApiStatsDay implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "STAT_TIME", nullable = false)
|
||||||
|
private LocalDate statTime;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||||
|
private String gwInstanceId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||||
|
private String bizDivCode = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||||
|
private String clientId = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String inboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String outboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Column(name = "TOTAL_CNT", nullable = false)
|
||||||
|
private Long totalCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||||
|
private Long successCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||||
|
private Long timeoutCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||||
|
private Long systemErrCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||||
|
private Long bizErrCnt = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 통계 일별 복합키
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiStatsDayId implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private LocalDate statTime;
|
||||||
|
private String apiName;
|
||||||
|
private String gwInstanceId;
|
||||||
|
private String bizDivCode;
|
||||||
|
private String clientId;
|
||||||
|
private String inboundAdapter;
|
||||||
|
private String outboundAdapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway 시간별 처리 통계 (AGWAPP.API_STATS_HOUR).
|
||||||
|
*
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다 — gateway datasource 의 hibernate.default_schema(AGWAPP)로 해석된다.
|
||||||
|
* 포털은 읽기 전용으로만 사용한다(집계 주체는 eapim-admin 스케줄러). 사용 컬럼(카운트)만 매핑한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "API_STATS_HOUR")
|
||||||
|
@IdClass(ApiStatsHourId.class)
|
||||||
|
public class ApiStatsHour implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "STAT_TIME", nullable = false)
|
||||||
|
private LocalDateTime statTime;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||||
|
private String gwInstanceId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||||
|
private String bizDivCode = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||||
|
private String clientId = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String inboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String outboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Column(name = "TOTAL_CNT", nullable = false)
|
||||||
|
private Long totalCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||||
|
private Long successCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||||
|
private Long timeoutCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||||
|
private Long systemErrCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||||
|
private Long bizErrCnt = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 통계 시간별 복합키
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiStatsHourId implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private LocalDateTime statTime;
|
||||||
|
private String apiName;
|
||||||
|
private String gwInstanceId;
|
||||||
|
private String bizDivCode;
|
||||||
|
private String clientId;
|
||||||
|
private String inboundAdapter;
|
||||||
|
private String outboundAdapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway 월별 처리 통계 (AGWAPP.API_STATS_MONTH).
|
||||||
|
*
|
||||||
|
* STAT_TIME 은 YYYYMM 문자열. 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
* 포털은 읽기 전용으로만 사용한다. 사용 컬럼(카운트)만 매핑한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "API_STATS_MONTH")
|
||||||
|
@IdClass(ApiStatsMonthId.class)
|
||||||
|
public class ApiStatsMonth implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "STAT_TIME", nullable = false, length = 6)
|
||||||
|
private String statTime; // YYYYMM format
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_NAME", nullable = false, length = 100)
|
||||||
|
private String apiName;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
|
||||||
|
private String gwInstanceId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
|
||||||
|
private String bizDivCode = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENT_ID", nullable = false, length = 100)
|
||||||
|
private String clientId = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String inboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
|
||||||
|
private String outboundAdapter = "NONE";
|
||||||
|
|
||||||
|
@Column(name = "TOTAL_CNT", nullable = false)
|
||||||
|
private Long totalCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SUCCESS_CNT", nullable = false)
|
||||||
|
private Long successCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "TIMEOUT_CNT", nullable = false)
|
||||||
|
private Long timeoutCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
|
||||||
|
private Long systemErrCnt = 0L;
|
||||||
|
|
||||||
|
@Column(name = "BIZ_ERR_CNT", nullable = false)
|
||||||
|
private Long bizErrCnt = 0L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 통계 월별 복합키
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiStatsMonthId implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String statTime; // YYYYMM format
|
||||||
|
private String apiName;
|
||||||
|
private String gwInstanceId;
|
||||||
|
private String bizDivCode;
|
||||||
|
private String clientId;
|
||||||
|
private String inboundAdapter;
|
||||||
|
private String outboundAdapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
|
||||||
|
*
|
||||||
|
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
|
||||||
|
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "API_STATUS")
|
||||||
|
@Data
|
||||||
|
public class GwApiStatus {
|
||||||
|
|
||||||
|
/** EAI 서비스명 */
|
||||||
|
@Id
|
||||||
|
@Column(name = "EAISVCNAME", length = 30)
|
||||||
|
private String eaisvcname;
|
||||||
|
|
||||||
|
/** N 정상 / C 점검 / D 지연 / E 장애 */
|
||||||
|
@Column(name = "STATUS_CODE", length = 1)
|
||||||
|
private String statusCode;
|
||||||
|
|
||||||
|
@Column(name = "MODIFIED_BY", length = 20)
|
||||||
|
private String modifiedBy;
|
||||||
|
|
||||||
|
@Column(name = "MODIFIED_DATE")
|
||||||
|
private LocalDateTime modifiedDate;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GW 인증 Client 정보 (AGWAPP.TSEAIAU01) — 읽기 전용.
|
||||||
|
*
|
||||||
|
* API 통계의 org 스코핑에 사용한다: 통계 테이블에는 org_id 가 없으므로
|
||||||
|
* TSEAIAU01.ORGID 로 org 의 CLIENTID 목록을 구해 API_STATS_*.CLIENT_ID 와 매칭한다.
|
||||||
|
* (admin ApiUseStatsService 도 API_STATS.CLIENT_ID = TSEAIAU01.CLIENTID 로 조인)
|
||||||
|
*
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "TSEAIAU01")
|
||||||
|
public class GwAuthClient implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CLIENTID", nullable = false, length = 256)
|
||||||
|
private String clientId;
|
||||||
|
|
||||||
|
@Column(name = "CLIENTNAME", length = 150)
|
||||||
|
private String clientName;
|
||||||
|
|
||||||
|
@Column(name = "ORGID", length = 38)
|
||||||
|
private String orgId;
|
||||||
|
|
||||||
|
@Column(name = "APPSTATUS", length = 1)
|
||||||
|
private String appStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GW 인터페이스(서비스) 정보 (AGWAPP.TSEAIHE01) — 읽기 전용.
|
||||||
|
*
|
||||||
|
* API_STATS_*.API_NAME 은 인터페이스 ID(=EAISVCNAME)이며, 화면 표시용 실제 명칭은 EAISVCDESC 다.
|
||||||
|
* (admin ApiUseStatsService 도 API_STATS.API_NAME = TSEAIHE01.EAISVCNAME 으로 조인)
|
||||||
|
* 스키마명은 소스에 하드코딩하지 않는다(gateway default_schema=AGWAPP).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "TSEAIHE01")
|
||||||
|
public class GwSvcInfo implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "EAISVCNAME", nullable = false, length = 30)
|
||||||
|
private String svcName; // 인터페이스 ID (= API_STATS.API_NAME)
|
||||||
|
|
||||||
|
@Column(name = "EAISVCDESC", length = 400)
|
||||||
|
private String svcDesc; // 인터페이스 표시 명칭
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDay;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsDayId;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API_STATS_DAY(AGWAPP) 조회 리포지토리.
|
||||||
|
* 월별 조회의 "현재 월" 분(아직 API_STATS_MONTH 미집계)을 일 범위로 합산하는 데 사용한다.
|
||||||
|
*/
|
||||||
|
public interface ApiStatsDayRepository extends JpaRepository<ApiStatsDay, ApiStatsDayId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 요약 (일자 범위, 지정 clientId 집합).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsDay s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startDate AND :endDate")
|
||||||
|
ApiStatisticsSummaryDto findSummary(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startDate") LocalDate startDate,
|
||||||
|
@Param("endDate") LocalDate endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 통계 (API_NAME 단위 집계).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||||
|
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsDay s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||||
|
"GROUP BY s.apiName " +
|
||||||
|
"ORDER BY SUM(s.totalCnt) DESC")
|
||||||
|
List<ApiStatisticsDetailDto> findDetail(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startDate") LocalDate startDate,
|
||||||
|
@Param("endDate") LocalDate endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일자별 누적 통계 (월별 조회 하단 표에 사용 — 월별이라도 일별로 표기).
|
||||||
|
* 생성자 표현식 안의 FUNCTION 은 Hibernate 5.6 이 타입 해석을 못 하므로 Object[] 로 받는다.
|
||||||
|
* row: [0]=일자(String yyyy-MM-dd), [1]=total, [2]=success, [3]=timeout, [4]=systemErr, [5]=biz
|
||||||
|
*/
|
||||||
|
@Query("SELECT FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD'), " +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0) " +
|
||||||
|
"FROM ApiStatsDay s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startDate AND :endDate " +
|
||||||
|
"GROUP BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD') " +
|
||||||
|
"ORDER BY FUNCTION('TO_CHAR', s.statTime, 'YYYY-MM-DD')")
|
||||||
|
List<Object[]> findPeriodByDay(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startDate") LocalDate startDate,
|
||||||
|
@Param("endDate") LocalDate endDate);
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHour;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsHourId;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API_STATS_HOUR(AGWAPP) 조회 리포지토리.
|
||||||
|
*
|
||||||
|
* 통계 수치는 보존기간이 긴 API_STATS_DAY 를 사용하되, "오늘"은 아직 DAY 로 집계되지 않았으므로
|
||||||
|
* (일 집계 잡은 익일 실행) 오늘분만 HOUR 에서 합산해 DAY 결과에 더한다. 최신 집계 시각도 HOUR 로 산출.
|
||||||
|
*/
|
||||||
|
public interface ApiStatsHourRepository extends JpaRepository<ApiStatsHour, ApiStatsHourId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 요약 (시각 범위, 지정 clientId 집합) — 오늘분 합산용.
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsHour s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startTime AND :endTime")
|
||||||
|
ApiStatisticsSummaryDto findSummary(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 통계 (API_NAME 단위 집계) — 오늘분 합산용.
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||||
|
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsHour s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startTime AND :endTime " +
|
||||||
|
"GROUP BY s.apiName " +
|
||||||
|
"ORDER BY SUM(s.totalCnt) DESC")
|
||||||
|
List<ApiStatisticsDetailDto> findDetail(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startTime") LocalDateTime startTime,
|
||||||
|
@Param("endTime") LocalDateTime endTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최신 집계 시각 (안내 문구용). 데이터 없으면 null.
|
||||||
|
*/
|
||||||
|
@Query("SELECT MAX(s.statTime) FROM ApiStatsHour s WHERE s.clientId IN :clientIds")
|
||||||
|
LocalDateTime findLatestStatTime(@Param("clientIds") List<String> clientIds);
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonth;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.ApiStatsMonthId;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API_STATS_MONTH(AGWAPP) 통계 조회 리포지토리 (월별 모드).
|
||||||
|
* STAT_TIME 은 YYYYMM 문자열이므로 문자열 범위 비교로 조회한다.
|
||||||
|
*/
|
||||||
|
public interface ApiStatsMonthRepository extends JpaRepository<ApiStatsMonth, ApiStatsMonthId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 요약 (YYYYMM 범위, 지정 clientId 집합).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto(" +
|
||||||
|
"COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), COALESCE(SUM(s.timeoutCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsMonth s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startMonth AND :endMonth")
|
||||||
|
ApiStatisticsSummaryDto findSummary(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startMonth") String startMonth,
|
||||||
|
@Param("endMonth") String endMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API별 통계 (API_NAME 단위 집계).
|
||||||
|
*/
|
||||||
|
@Query("SELECT new com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsDetailDto(" +
|
||||||
|
"s.apiName, COALESCE(SUM(s.totalCnt), 0), COALESCE(SUM(s.successCnt), 0), " +
|
||||||
|
"COALESCE(SUM(s.timeoutCnt), 0), COALESCE(SUM(s.systemErrCnt), 0), COALESCE(SUM(s.bizErrCnt), 0)) " +
|
||||||
|
"FROM ApiStatsMonth s " +
|
||||||
|
"WHERE s.clientId IN :clientIds " +
|
||||||
|
"AND s.statTime BETWEEN :startMonth AND :endMonth " +
|
||||||
|
"GROUP BY s.apiName " +
|
||||||
|
"ORDER BY SUM(s.totalCnt) DESC")
|
||||||
|
List<ApiStatisticsDetailDto> findDetail(
|
||||||
|
@Param("clientIds") List<String> clientIds,
|
||||||
|
@Param("startMonth") String startMonth,
|
||||||
|
@Param("endMonth") String endMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 가능한 월 목록 (YYYYMM, 최신순).
|
||||||
|
*/
|
||||||
|
@Query("SELECT DISTINCT s.statTime FROM ApiStatsMonth s " +
|
||||||
|
"WHERE s.clientId IN :clientIds ORDER BY s.statTime DESC")
|
||||||
|
List<String> findAvailableMonths(@Param("clientIds") List<String> clientIds);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
|
||||||
|
*/
|
||||||
|
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
|
||||||
|
Optional<LocalDateTime> findLastModifiedDate();
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.GwAuthClient;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSEAIAU01(AGWAPP 인증 Client) 조회 — API 통계 org 스코핑용.
|
||||||
|
* PTL_ORG.ID == TSEAIAU01.ORGID (1:N), TSEAIAU01.CLIENTID == API_STATS_*.CLIENT_ID.
|
||||||
|
*/
|
||||||
|
public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, String> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* org 소속 클라이언트 목록 (앱 선택 드롭다운용).
|
||||||
|
*/
|
||||||
|
List<GwAuthClient> findByOrgIdOrderByClientNameAsc(String orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* org 소속 CLIENTID 목록 (통계 필터용).
|
||||||
|
*/
|
||||||
|
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||||
|
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.eactive.apim.gateway.data.statistics.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.gateway.data.statistics.entity.GwSvcInfo;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSEAIHE01(AGWAPP 인터페이스 정보) 조회 — API_NAME(인터페이스 ID) → 표시명(EAISVCDESC) 매핑용.
|
||||||
|
*/
|
||||||
|
public interface GwSvcInfoRepository extends JpaRepository<GwSvcInfo, String> {
|
||||||
|
|
||||||
|
List<GwSvcInfo> findBySvcNameIn(Collection<String> svcNames);
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
|||||||
|
|
||||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||||
|
|
||||||
|
public PortalApplication() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|
||||||
portal_logger.info("##### PortalApplication Start #####");
|
portal_logger.info("##### PortalApplication Start #####");
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.eactive.apim.portal.apps;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readiness probe.
|
||||||
|
*
|
||||||
|
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
|
||||||
|
* This endpoint validates both EMS and Gateway datasources via JDBC
|
||||||
|
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
|
||||||
|
* requests that depend on the database.
|
||||||
|
*
|
||||||
|
* <p>HTTP 200 + JSON when all checks pass.
|
||||||
|
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
|
||||||
|
* status so CI/CD logs the failing dependency.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ReadinessController {
|
||||||
|
|
||||||
|
private static final int VALIDATION_TIMEOUT_SECONDS = 2;
|
||||||
|
|
||||||
|
private final DataSource portalDataSource;
|
||||||
|
private final DataSource gatewayDataSource;
|
||||||
|
|
||||||
|
public ReadinessController(
|
||||||
|
@Qualifier("portalDataSource") DataSource portalDataSource,
|
||||||
|
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
|
||||||
|
this.portalDataSource = portalDataSource;
|
||||||
|
this.gatewayDataSource = gatewayDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ResponseEntity<String> ready() {
|
||||||
|
String ems = check(portalDataSource);
|
||||||
|
String gw = check(gatewayDataSource);
|
||||||
|
boolean ok = "UP".equals(ems) && "UP".equals(gw);
|
||||||
|
|
||||||
|
String body = "{"
|
||||||
|
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
||||||
|
+ "\"ems\":\"" + ems + "\","
|
||||||
|
+ "\"gateway\":\""+ gw + "\""
|
||||||
|
+ "}";
|
||||||
|
|
||||||
|
return ResponseEntity.status(ok ? 200 : 503)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String check(DataSource ds) {
|
||||||
|
if (ds == null) return "DOWN:NO_DATASOURCE";
|
||||||
|
try (Connection c = ds.getConnection()) {
|
||||||
|
return c.isValid(VALIDATION_TIMEOUT_SECONDS) ? "UP" : "DOWN:INVALID";
|
||||||
|
} catch (SQLException e) {
|
||||||
|
String msg = e.getMessage() == null ? "" : e.getMessage();
|
||||||
|
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-9
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/agreements")
|
@RequestMapping("/agreements")
|
||||||
public class AgreementsController {
|
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;
|
private final AgreementsFacade agreementsFacade;
|
||||||
|
|
||||||
@@ -28,28 +32,49 @@ public class AgreementsController {
|
|||||||
public String showTerms(@RequestParam(required = false) String tab,
|
public String showTerms(@RequestParam(required = false) String tab,
|
||||||
@RequestParam(required = false) String publishedOn,
|
@RequestParam(required = false) String publishedOn,
|
||||||
Model model) {
|
Model model) {
|
||||||
|
String currentTab = tab != null ? tab : "terms";
|
||||||
|
|
||||||
|
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
|
||||||
|
if ("privacy".equals(currentTab)) {
|
||||||
|
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
|
||||||
|
}
|
||||||
|
|
||||||
AgreementType type;
|
AgreementType type;
|
||||||
if ("privacy".equals(tab)) {
|
switch (currentTab) {
|
||||||
type = AgreementType.PRIVACY_POLICY;
|
case "privacy-collect":
|
||||||
} else {
|
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
|
||||||
type = AgreementType.TERMS_OF_USE;
|
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));
|
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
|
||||||
model.addAttribute("agreementsList", agreementsList);
|
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("selectedAgreement", selectedAgreement);
|
||||||
model.addAttribute("selectedDate", publishedOn);
|
model.addAttribute("selectedDate", publishedOn);
|
||||||
|
|
||||||
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
|
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("agreementTitle", type.getDescription());
|
||||||
model.addAttribute("agreementType", type.getCode());
|
model.addAttribute("agreementType", type.getCode());
|
||||||
|
|
||||||
model.addAttribute("currentTab", tab != null ? tab : "terms");
|
model.addAttribute("currentTab", currentTab);
|
||||||
|
|
||||||
return TERMS_AGREEMENTS;
|
return TERMS_AGREEMENTS;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
|||||||
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
|
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 개인정보수집동의서 처리
|
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
|
||||||
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
|
if (privacyCollectType != null
|
||||||
|
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
|
||||||
Optional<Agreements> privacyCollect = agreementsService
|
Optional<Agreements> privacyCollect = agreementsService
|
||||||
.findLastesBeforeDate(
|
.findLastesBeforeDate(
|
||||||
privacyCollectType,
|
privacyCollectType,
|
||||||
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
|
|||||||
defaultAgreement.setName("개인정보처리방침");
|
defaultAgreement.setName("개인정보처리방침");
|
||||||
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
break;
|
break;
|
||||||
|
case "PRIVACY_COLLECT":
|
||||||
|
defaultAgreement.setName("개인정보수집동의서");
|
||||||
|
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
break;
|
||||||
case "PRIVACY_COLLECT_IND":
|
case "PRIVACY_COLLECT_IND":
|
||||||
defaultAgreement.setName("개인용 개인정보수집동의서");
|
defaultAgreement.setName("개인용 개인정보수집동의서");
|
||||||
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
|||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -30,6 +32,7 @@ public class ApiController {
|
|||||||
private final ApiService apiService;
|
private final ApiService apiService;
|
||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
private final ApiSearchFacade apiSearchFacade;
|
private final ApiSearchFacade apiSearchFacade;
|
||||||
|
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||||
|
|
||||||
@@ -38,7 +41,24 @@ public class ApiController {
|
|||||||
if (id == null) {
|
if (id == null) {
|
||||||
return "redirect:/apis/common";
|
return "redirect:/apis/common";
|
||||||
}
|
}
|
||||||
|
populateDetailModel(id, model);
|
||||||
|
model.addAttribute("activeTab", "api-info");
|
||||||
|
return "apps/apis/mainApiDetail";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테스트베드를 API 정보와 별도 URL로 분리(딥링크·북마크 가능). 미인증 사용자도 페이지 진입은
|
||||||
|
// 허용하되, 실제 테스트베드(Swagger)는 인증 사용자에게만 렌더하고 미인증에는 로그인 안내를 노출한다.
|
||||||
|
@GetMapping("/detail/testbed")
|
||||||
|
public String apidetailTestbed(@RequestParam(value = "id", required = false) String id, ModelMap model) {
|
||||||
|
if (id == null) {
|
||||||
|
return "redirect:/apis/common";
|
||||||
|
}
|
||||||
|
populateDetailModel(id, model);
|
||||||
|
model.addAttribute("activeTab", "testbed");
|
||||||
|
return "apps/apis/mainApiDetail";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateDetailModel(String id, ModelMap model) {
|
||||||
ApiSpecInfoDto api = apiService.selectDetail(id);
|
ApiSpecInfoDto api = apiService.selectDetail(id);
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
||||||
@@ -46,10 +66,16 @@ public class ApiController {
|
|||||||
|
|
||||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
|
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
|
||||||
|
|
||||||
|
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
|
||||||
|
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
|
||||||
|
if (apiGroup != null) {
|
||||||
|
api.setApiGroupName(apiGroup.getGroupName());
|
||||||
|
}
|
||||||
|
|
||||||
model.addAttribute("apiSpecInfo", api);
|
model.addAttribute("apiSpecInfo", api);
|
||||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||||
model.addAttribute("services", searchResult.get("services"));
|
model.addAttribute("services", searchResult.get("services"));
|
||||||
return "apps/apis/mainApiDetail";
|
model.addAttribute("authenticated", SecurityUtil.isAuthenticated());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -62,12 +88,18 @@ public class ApiController {
|
|||||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||||
|
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
|
||||||
|
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
|
||||||
|
|
||||||
return "apps/apis/mainApiList";
|
return "apps/apis/mainApiList";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/testbed/api")
|
@GetMapping("/testbed/api")
|
||||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
|
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||||
|
if (!SecurityUtil.isAuthenticated()) {
|
||||||
|
return "redirect:/login?reason=auth";
|
||||||
|
}
|
||||||
String selectedApiServiceName = "API 서비스 선택";
|
String selectedApiServiceName = "API 서비스 선택";
|
||||||
String selectedServiceId = "";
|
String selectedServiceId = "";
|
||||||
boolean idExists = false;
|
boolean idExists = false;
|
||||||
@@ -93,6 +125,10 @@ public class ApiController {
|
|||||||
|
|
||||||
@GetMapping("/testbed")
|
@GetMapping("/testbed")
|
||||||
public String testbedByApiService(@RequestParam(value = "id", required = false) String id, Model model) {
|
public String testbedByApiService(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
|
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||||
|
if (!SecurityUtil.isAuthenticated()) {
|
||||||
|
return "redirect:/login?reason=auth";
|
||||||
|
}
|
||||||
String selectedApiServiceName = "API 서비스 선택";
|
String selectedApiServiceName = "API 서비스 선택";
|
||||||
boolean idExists = false;
|
boolean idExists = false;
|
||||||
|
|
||||||
|
|||||||
+25
-15
@@ -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.entity.ApiSpecInfo;
|
||||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||||
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.util.FileCopyUtils;
|
import org.springframework.util.FileCopyUtils;
|
||||||
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/apis")
|
@RequestMapping("/api/apis")
|
||||||
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class TestbedSpecController {
|
public class TestbedSpecController {
|
||||||
|
|
||||||
@Autowired
|
private final ApiSpecInfoService apiSpecInfoService;
|
||||||
private ApiSpecInfoService apiSpecInfoService;
|
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||||
|
|
||||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
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";
|
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||||
|
|
||||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
@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)) {
|
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||||
try {
|
try {
|
||||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||||
return ResponseEntity.ok().body(content);
|
return serverRewriter.rewriteServer(content, null, request);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Failed to read default token api spec file", 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);
|
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||||
|
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||||
if (!spec.isPresent()) {
|
return null;
|
||||||
StringUtils.hasText(spec.get().getTestbedSpec());
|
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
|
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.eactive.apim.portal.apps.apis.filter;
|
package com.eactive.apim.portal.apps.apis.filter;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
|
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -20,15 +22,34 @@ public class APISender {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
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 {
|
public String requestPost(String uri, String requestBody) throws IOException {
|
||||||
|
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
|
||||||
|
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||||
|
}
|
||||||
|
|
||||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||||
|
|
||||||
String response = getResponse(connection);
|
String response = getResponse(connection);
|
||||||
|
|
||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
if (logger.isDebugEnabled()) {
|
if (logger.isDebugEnabled()) {
|
||||||
logger.debug(response);
|
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -58,6 +79,7 @@ public class APISender {
|
|||||||
|
|
||||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||||
|
applyTimeout(connection);
|
||||||
connection.setRequestMethod("GET");
|
connection.setRequestMethod("GET");
|
||||||
connection.setRequestProperty("Accept", "application/json");
|
connection.setRequestProperty("Accept", "application/json");
|
||||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||||
@@ -69,11 +91,15 @@ public class APISender {
|
|||||||
}
|
}
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
|
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
|
||||||
|
}
|
||||||
|
|
||||||
String response = getResponse(connection);
|
String response = getResponse(connection);
|
||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
|
|
||||||
if (logger.isDebugEnabled()) {
|
if (logger.isDebugEnabled()) {
|
||||||
logger.debug(response);
|
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -82,6 +108,7 @@ public class APISender {
|
|||||||
|
|
||||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||||
|
applyTimeout(connection);
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
|
|
||||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||||
@@ -93,6 +120,12 @@ public class APISender {
|
|||||||
}
|
}
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
|
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
|
||||||
|
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
|
||||||
|
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||||
|
}
|
||||||
|
|
||||||
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
|
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
|
||||||
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
|
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
|
||||||
outputStream.write(requestBodyBytes);
|
outputStream.write(requestBodyBytes);
|
||||||
@@ -103,7 +136,7 @@ public class APISender {
|
|||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
|
|
||||||
if (logger.isDebugEnabled()) {
|
if (logger.isDebugEnabled()) {
|
||||||
logger.debug(response);
|
logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -125,10 +158,11 @@ public class APISender {
|
|||||||
return response.toString();
|
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);
|
URL endpoint = new URL(uri);
|
||||||
|
|
||||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||||
|
applyTimeout(connection);
|
||||||
|
|
||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package com.eactive.apim.portal.apps.apis.filter;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
|
||||||
|
*
|
||||||
|
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
|
||||||
|
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
|
||||||
|
*
|
||||||
|
* <p>마스킹 정책:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
|
||||||
|
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
|
||||||
|
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ApiTesterAuditLogger {
|
||||||
|
|
||||||
|
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 값 전체를 마스킹할 키(소문자 비교) */
|
||||||
|
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
|
||||||
|
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||||
|
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
|
||||||
|
|
||||||
|
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
|
||||||
|
private static final String[] AUDIT_HEADERS = {
|
||||||
|
"content-type", "accept", "referer", "origin", "x-forwarded-for",
|
||||||
|
"original-api-id", "authorization"};
|
||||||
|
|
||||||
|
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
|
||||||
|
private static final int JSON_LOG_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
|
||||||
|
private static final int NON_JSON_PREVIEW_LENGTH = 8;
|
||||||
|
|
||||||
|
private ApiTesterAuditLogger() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
|
||||||
|
public static String newAuditId() {
|
||||||
|
return UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
|
||||||
|
*
|
||||||
|
* @param targetUrl original-url 헤더 값 (없으면 null)
|
||||||
|
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
|
||||||
|
* @param tokenRequest OAuth 토큰 발급 요청 여부
|
||||||
|
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
|
||||||
|
*/
|
||||||
|
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
|
||||||
|
String gatewayMode, boolean tokenRequest, String body) {
|
||||||
|
try {
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
sb.append("REQ [").append(auditId).append(']');
|
||||||
|
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
|
||||||
|
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
|
||||||
|
sb.append(" user=").append(currentUser());
|
||||||
|
sb.append(" method=").append(request.getMethod());
|
||||||
|
sb.append(" mode=").append(gatewayMode);
|
||||||
|
sb.append(" token=").append(tokenRequest);
|
||||||
|
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
|
||||||
|
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
|
||||||
|
sb.append(" headers=").append(buildHeaderSummary(request));
|
||||||
|
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
|
||||||
|
auditLogger.info(sb.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
|
||||||
|
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
|
||||||
|
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 요청 정보 구성
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
|
||||||
|
private static String currentUser() {
|
||||||
|
try {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
|
||||||
|
return "anonymous";
|
||||||
|
}
|
||||||
|
String name = auth.getName();
|
||||||
|
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
|
||||||
|
private static String buildHeaderSummary(HttpServletRequest request) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
boolean first = true;
|
||||||
|
for (String name : AUDIT_HEADERS) {
|
||||||
|
String value = request.getHeader(name);
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!first) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
|
||||||
|
}
|
||||||
|
return sb.append('}').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
|
||||||
|
private static String maskHeaderValue(String name, String value) {
|
||||||
|
if (!SECRET_KEYS.contains(name.toLowerCase())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
int space = value.indexOf(' ');
|
||||||
|
if (space > 0) {
|
||||||
|
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
|
||||||
|
}
|
||||||
|
return partialMask(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 본문 마스킹
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
|
||||||
|
if (body == null || body.isEmpty()) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
|
||||||
|
if (tokenRequest) {
|
||||||
|
return "\"" + maskFormBody(body) + "\"";
|
||||||
|
}
|
||||||
|
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
|
||||||
|
if (contentType != null && contentType.toLowerCase().contains("json")) {
|
||||||
|
String maskedJson = tryMaskJson(body);
|
||||||
|
if (maskedJson != null) {
|
||||||
|
return maskedJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nonJsonSummary(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
|
||||||
|
private static String maskFormBody(String body) {
|
||||||
|
StringBuilder sb = new StringBuilder(body.length());
|
||||||
|
String[] pairs = body.split("&");
|
||||||
|
for (int i = 0; i < pairs.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append('&');
|
||||||
|
}
|
||||||
|
int eq = pairs[i].indexOf('=');
|
||||||
|
if (eq < 0) {
|
||||||
|
sb.append(partialMask(pairs[i]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = pairs[i].substring(0, eq);
|
||||||
|
String value = pairs[i].substring(eq + 1);
|
||||||
|
sb.append(key).append('=');
|
||||||
|
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
|
||||||
|
}
|
||||||
|
return sanitize(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
|
||||||
|
private static String maskQueryValues(String url) {
|
||||||
|
int qs = url.indexOf('?');
|
||||||
|
if (qs < 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
|
||||||
|
private static String tryMaskJson(String body) {
|
||||||
|
try {
|
||||||
|
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
|
||||||
|
String out = OBJECT_MAPPER.writeValueAsString(masked);
|
||||||
|
if (out.length() > JSON_LOG_MAX_LENGTH) {
|
||||||
|
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
|
||||||
|
private static JsonNode maskJsonNode(JsonNode node) {
|
||||||
|
if (node.isObject()) {
|
||||||
|
ObjectNode obj = (ObjectNode) node;
|
||||||
|
Iterator<String> names = obj.fieldNames();
|
||||||
|
Set<String> fieldNames = new HashSet<>();
|
||||||
|
while (names.hasNext()) {
|
||||||
|
fieldNames.add(names.next());
|
||||||
|
}
|
||||||
|
for (String field : fieldNames) {
|
||||||
|
if (SECRET_KEYS.contains(field.toLowerCase())) {
|
||||||
|
obj.set(field, TextNode.valueOf("*****"));
|
||||||
|
} else {
|
||||||
|
obj.set(field, maskJsonNode(obj.get(field)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
ArrayNode arr = (ArrayNode) node;
|
||||||
|
for (int i = 0; i < arr.size(); i++) {
|
||||||
|
arr.set(i, maskJsonNode(arr.get(i)));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
if (node.isNull() || node.isMissingNode()) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return TextNode.valueOf(partialMask(node.asText()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
|
||||||
|
private static String nonJsonSummary(String body) {
|
||||||
|
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
|
||||||
|
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
|
||||||
|
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 공통 helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
|
||||||
|
private static String partialMask(String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int len = value.length();
|
||||||
|
if (len <= 2) {
|
||||||
|
return stars(len);
|
||||||
|
}
|
||||||
|
int visible = Math.min(4, Math.max(1, len / 3));
|
||||||
|
return value.substring(0, visible) + "***";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stars(int count) {
|
||||||
|
char[] arr = new char[count];
|
||||||
|
Arrays.fill(arr, '*');
|
||||||
|
return new String(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
|
||||||
|
private static String sanitize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '"') {
|
||||||
|
sb.append('\'');
|
||||||
|
} else if (c == '\r' || c == '\n' || c == '\t') {
|
||||||
|
sb.append(' ');
|
||||||
|
} else if (c < 0x20) {
|
||||||
|
sb.append('?');
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ package com.eactive.apim.portal.apps.apis.filter;
|
|||||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
|
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -20,6 +23,7 @@ import javax.servlet.ServletRequest;
|
|||||||
import javax.servlet.ServletResponse;
|
import javax.servlet.ServletResponse;
|
||||||
import javax.servlet.annotation.WebFilter;
|
import javax.servlet.annotation.WebFilter;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -56,20 +60,53 @@ public class ApiTesterFilter implements Filter {
|
|||||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
|
||||||
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
|
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
|
||||||
|
|
||||||
if (httpServletRequest.getRequestURI().contains("/api/call-api")) {
|
// "/api/call-api" 경로가 아닌 경우 필터링하지 않고 다음 필터로 넘어감
|
||||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
if (!httpServletRequest.getRequestURI().contains("/api/call-api")) {
|
||||||
String url = httpServletRequest.getHeader("original-url");
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.contains("/api/v1/oauth/token")){
|
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||||
StringBuilder sb = new StringBuilder();
|
String url = httpServletRequest.getHeader("original-url");
|
||||||
BufferedReader reader = httpServletRequest.getReader();
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
sb.append(line);
|
|
||||||
}
|
|
||||||
String body = sb.toString();
|
|
||||||
|
|
||||||
// Parse request parameters
|
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
|
||||||
|
String auditId = ApiTesterAuditLogger.newAuditId();
|
||||||
|
long auditStart = System.currentTimeMillis();
|
||||||
|
String auditType = "-";
|
||||||
|
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
|
||||||
|
String proxyTarget = null;
|
||||||
|
|
||||||
|
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||||
|
if (url == null || url.trim().isEmpty()) {
|
||||||
|
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
|
||||||
|
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
|
||||||
|
System.currentTimeMillis() - auditStart);
|
||||||
|
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
|
||||||
|
// 반환하기 위해 try 로 감싼다.
|
||||||
|
try {
|
||||||
|
|
||||||
|
// 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
|
||||||
|
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
|
||||||
|
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
|
||||||
|
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||||
|
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
|
||||||
|
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
|
||||||
|
|
||||||
|
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||||
|
String requestBody = readBody(httpServletRequest);
|
||||||
|
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
|
||||||
|
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
|
||||||
|
|
||||||
|
if (tokenRequest) {
|
||||||
|
String body = requestBody;
|
||||||
|
|
||||||
|
if (mockTokenRequest) {
|
||||||
|
auditType = "TOKEN_MOCK";
|
||||||
|
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
|
||||||
Map<String, String> params = new HashMap<>();
|
Map<String, String> params = new HashMap<>();
|
||||||
String[] pairs = body.split("&");
|
String[] pairs = body.split("&");
|
||||||
for (String pair : pairs) {
|
for (String pair : pairs) {
|
||||||
@@ -81,7 +118,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
String scope = params.getOrDefault("scope", "default");
|
String scope = params.getOrDefault("scope", "default");
|
||||||
|
|
||||||
String token = "{\n" +
|
String token = "{\n" +
|
||||||
" \"access_token\": \"kjbank_gw_sample_token\",\n" +
|
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
|
||||||
" \"token_type\": \"bearer\",\n" +
|
" \"token_type\": \"bearer\",\n" +
|
||||||
" \"expires_in\": 86400,\n" +
|
" \"expires_in\": 86400,\n" +
|
||||||
" \"scope\": \""+scope +"\",\n" +
|
" \"scope\": \""+scope +"\",\n" +
|
||||||
@@ -90,64 +127,250 @@ public class ApiTesterFilter implements Filter {
|
|||||||
|
|
||||||
response.setContentType("application/json");
|
response.setContentType("application/json");
|
||||||
response.getWriter().println(token);
|
response.getWriter().println(token);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||||
|
auditType = "TOKEN_GW";
|
||||||
|
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||||
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||||
|
headers.put("Accept", "application/json");
|
||||||
|
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||||
|
proxyTarget = target;
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
|
||||||
|
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
|
||||||
|
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
|
||||||
|
}
|
||||||
|
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||||
|
|
||||||
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
|
response.setContentType("application/json");
|
||||||
response.setContentType("application/json");
|
response.getWriter().println(tokenResponse);
|
||||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
}
|
||||||
} else {
|
|
||||||
String mockUrl = apiSpecInfoDto.getMockUrl();
|
|
||||||
String method = apiSpecInfoDto.getApiMethod();
|
|
||||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
} else {
|
||||||
while (headerNames.hasMoreElements()) {
|
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
||||||
String header = headerNames.nextElement();
|
|
||||||
headers.put(header, httpServletRequest.getHeader(header));
|
|
||||||
}
|
|
||||||
Map<String, String[]> paramMap = new HashMap<>();
|
|
||||||
|
|
||||||
String originalUrl = headers.get("original-url");
|
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||||
//sprlit original url with ? get the second part then split with & put into paramMap
|
if (apiSpecInfoDto == null) {
|
||||||
|
auditType = "SPEC_NOT_FOUND";
|
||||||
|
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||||
|
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String[] originalUrlArr = originalUrl.split("\\?");
|
String responseType = apiSpecInfoDto.getResponseType();
|
||||||
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");
|
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||||
headers.remove("original-api-id");
|
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
auditType = "SAMPLE";
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String responseStr = "";
|
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
|
||||||
if (method.equalsIgnoreCase("post")) {
|
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||||
StringBuilder sb = new StringBuilder();
|
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||||
BufferedReader reader = httpServletRequest.getReader();
|
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||||
String line;
|
auditType = gw ? "GW" : "MOCK";
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
sb.append(line);
|
|
||||||
}
|
|
||||||
String body = sb.toString();
|
|
||||||
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
|
|
||||||
|
|
||||||
} else {
|
Map<String, String> headers = new HashMap<>();
|
||||||
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
|
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||||
}
|
while (headerNames.hasMoreElements()) {
|
||||||
response.setContentType("application/json");
|
String header = headerNames.nextElement();
|
||||||
response.getWriter().println(responseStr);
|
headers.put(header, httpServletRequest.getHeader(header));
|
||||||
|
}
|
||||||
|
headers.remove("original-url");
|
||||||
|
headers.remove("original-api-id");
|
||||||
|
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
|
||||||
|
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
|
||||||
|
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
|
||||||
|
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
|
||||||
|
|
||||||
|
String targetUri;
|
||||||
|
Map<String, String[]> paramMap;
|
||||||
|
if (gw) {
|
||||||
|
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
|
||||||
|
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||||
|
headers.remove("host");
|
||||||
|
headers.remove("Host");
|
||||||
|
targetUri = url;
|
||||||
|
paramMap = new HashMap<>();
|
||||||
|
} else {
|
||||||
|
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||||
|
targetUri = apiSpecInfoDto.getMockUrl();
|
||||||
|
paramMap = extractQueryParams(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyTarget = targetUri;
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
// GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
|
||||||
|
// 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
|
||||||
|
logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
|
||||||
|
auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
|
||||||
|
apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
|
||||||
|
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
|
||||||
|
}
|
||||||
|
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||||
|
String responseStr;
|
||||||
|
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||||
|
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||||
|
} else {
|
||||||
|
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||||
|
}
|
||||||
|
if (logger.isDebugEnabled()) {
|
||||||
|
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
|
||||||
|
auditType, auditId, targetUri,
|
||||||
|
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
|
||||||
|
}
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().println(responseStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (java.net.SocketTimeoutException e) {
|
||||||
|
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||||
|
logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||||
|
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||||
|
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
|
||||||
|
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||||
|
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||||
|
} catch (IOException e) {
|
||||||
|
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
|
||||||
|
logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||||
|
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||||
|
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||||
|
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||||
|
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||||
|
logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||||
|
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||||
|
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||||
|
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||||
|
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||||
|
} finally {
|
||||||
|
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
|
||||||
|
System.currentTimeMillis() - auditStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 본문 전체를 문자열로 읽는다.
|
||||||
|
*
|
||||||
|
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
|
||||||
|
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
|
||||||
|
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
|
||||||
|
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (sb.length() == 0 && isFormUrlEncoded(request)) {
|
||||||
|
String rebuilt = rebuildFormBodyFromParams(request);
|
||||||
|
if (!rebuilt.isEmpty()) {
|
||||||
|
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
|
||||||
|
return rebuilt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
|
||||||
|
private boolean isFormUrlEncoded(HttpServletRequest request) {
|
||||||
|
String contentType = request.getContentType();
|
||||||
|
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
|
||||||
|
private String rebuildFormBodyFromParams(HttpServletRequest request) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||||
|
for (String value : entry.getValue()) {
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append('&');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
|
||||||
|
.append('=')
|
||||||
|
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
|
||||||
|
} catch (java.io.UnsupportedEncodingException e) {
|
||||||
|
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
chain.doFilter(request, response);
|
|
||||||
}
|
}
|
||||||
|
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]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paramMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 상태코드 + JSON 본문 응답. */
|
||||||
|
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
|
||||||
|
private String maskHeaders(Map<String, String> headers) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||||
|
if (sb.length() > 1) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
|
||||||
|
}
|
||||||
|
return sb.append('}').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
|
||||||
|
private String previewOf(String body) {
|
||||||
|
if (body == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
String flat = body.replaceAll("\\s+", " ").trim();
|
||||||
|
return flat.length() > 300 ? flat.substring(0, 300) + "…" : flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
@Override
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.apim.portal.apps.app.controller;
|
package com.eactive.apim.portal.apps.app.controller;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||||
|
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
|
||||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
@@ -11,6 +12,10 @@ import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
|||||||
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
||||||
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
||||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||||
|
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
@@ -21,6 +26,7 @@ import java.util.Map;
|
|||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -44,8 +50,9 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.web.servlet.ModelAndView;
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/myapikey")
|
@RequestMapping("/clients")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
|
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
|
||||||
public class MyAppController {
|
public class MyAppController {
|
||||||
@@ -77,6 +84,8 @@ public class MyAppController {
|
|||||||
private final ApiService apiService;
|
private final ApiService apiService;
|
||||||
private final ApiServiceHelper apiServiceHelper;
|
private final ApiServiceHelper apiServiceHelper;
|
||||||
private final FileTypeDetector fileTypeDetector;
|
private final FileTypeDetector fileTypeDetector;
|
||||||
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
|
||||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
@@ -108,14 +117,14 @@ public class MyAppController {
|
|||||||
@Secured("ROLE_APP")
|
@Secured("ROLE_APP")
|
||||||
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
|
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
|
||||||
|
|
||||||
if (appRequest == null) {
|
if (appRequest == null) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 목록 조회 및 설정
|
// API 목록 조회 및 설정
|
||||||
@@ -146,14 +155,14 @@ public class MyAppController {
|
|||||||
@Secured("ROLE_APP")
|
@Secured("ROLE_APP")
|
||||||
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
|
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
|
||||||
|
|
||||||
if (apiKey == null) {
|
if (apiKey == null) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 목록에 서비스 정보 추가
|
// API 목록에 서비스 정보 추가
|
||||||
@@ -168,7 +177,13 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client Secret은 초기 HTML에 담지 않는다. (비번 확인 후 서버가 1회만 반환)
|
||||||
|
boolean secretAvailable = StringUtils.isNotBlank(apiKey.getClientsecret());
|
||||||
|
apiKey.setClientsecret(null);
|
||||||
|
|
||||||
model.addAttribute("apiKey", apiKey);
|
model.addAttribute("apiKey", apiKey);
|
||||||
|
model.addAttribute("secretAvailable", secretAvailable);
|
||||||
|
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
|
||||||
|
|
||||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||||
}
|
}
|
||||||
@@ -207,19 +222,29 @@ public class MyAppController {
|
|||||||
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("message", "신청이 취소되었습니다.");
|
result.put("message", "신청이 취소되었습니다.");
|
||||||
} catch (Exception e) {
|
} catch (InvalidApprovalTransitionException e) {
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage());
|
result.put("message", "내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API Key 신청 취소 실패. id={}", id, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "신청 취소 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key를 삭제합니다.
|
* API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성)
|
||||||
* AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환
|
* 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와
|
||||||
|
* PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다.
|
||||||
|
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다.
|
||||||
*
|
*
|
||||||
* @param requestData 요청 데이터 (clientId와 type 포함)
|
* @param requestData 요청 데이터 (clientId, reason)
|
||||||
* @return 성공/실패 결과를 담은 Map
|
* @return 성공/실패 결과를 담은 Map
|
||||||
*/
|
*/
|
||||||
@PostMapping("/api_key_delete")
|
@PostMapping("/api_key_delete")
|
||||||
@@ -228,28 +253,51 @@ public class MyAppController {
|
|||||||
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
|
||||||
Map<String, Object> result = new java.util.HashMap<>();
|
Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
|
||||||
try {
|
String clientId = requestData.get("clientId");
|
||||||
String clientId = requestData.get("clientId");
|
if (clientId == null || clientId.trim().isEmpty()) {
|
||||||
String type = requestData.get("type");
|
|
||||||
|
|
||||||
if (clientId == null || clientId.trim().isEmpty()) {
|
|
||||||
result.put("success", false);
|
|
||||||
result.put("msg", "클라이언트 ID가 필요합니다.");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
|
||||||
|
|
||||||
// API Key 즉시 삭제 (승인 프로세스 없이)
|
|
||||||
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
|
|
||||||
|
|
||||||
result.put("success", true);
|
|
||||||
result.put("msg", "API Key가 삭제되었습니다.");
|
|
||||||
} catch (Exception e) {
|
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
result.put("msg", "클라이언트 ID가 필요합니다.");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String reason = requestData.get("reason");
|
||||||
|
if (reason == null || reason.trim().isEmpty()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "해지 사유를 입력해 주세요.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (reason.length() > 1000) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "해지 사유는 1000자 이내로 입력해 주세요.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
String orgId = user.getPortalOrg().getId();
|
||||||
|
|
||||||
|
// 1. 소유권 확인 (다른 조직의 인증키 해지 방지)
|
||||||
|
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리)
|
||||||
|
try {
|
||||||
|
appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg());
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", e.getMessage());
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("msg", UserErrorMessageResolver.resolveAsHtml(e));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +330,51 @@ public class MyAppController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||||
|
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
|
||||||
|
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
|
||||||
|
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
|
||||||
|
*
|
||||||
|
* @param requestData clientId 포함
|
||||||
|
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
|
||||||
|
*/
|
||||||
|
@PostMapping("/credential/reveal-secret")
|
||||||
|
@Secured("ROLE_APP")
|
||||||
|
@ResponseBody
|
||||||
|
public Map<String, Object> revealClientSecret(@RequestBody Map<String, String> requestData) {
|
||||||
|
Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
String clientId = requestData.get("clientId");
|
||||||
|
|
||||||
|
if (clientId == null || clientId.trim().isEmpty()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "클라이언트 ID가 필요합니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
|
||||||
|
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
|
||||||
|
try {
|
||||||
|
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
|
||||||
|
if (secret == null) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("alreadyRevealed", true);
|
||||||
|
result.put("message", "이미 1회 노출되어 삭제된 인증정보입니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("secret", secret);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Client Secret 노출 처리 실패 - clientId={}", clientId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "인증정보 조회 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key 요청 이력을 페이지 형태로 조회합니다.
|
* API Key 요청 이력을 페이지 형태로 조회합니다.
|
||||||
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
|
||||||
@@ -344,7 +437,7 @@ public class MyAppController {
|
|||||||
@Secured("ROLE_API_KEY_REQUEST_VIEW")
|
@Secured("ROLE_API_KEY_REQUEST_VIEW")
|
||||||
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
return new ModelAndView("redirect:/myapikey/api_key_request/history");
|
return new ModelAndView("redirect:/clients/api_key_request/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
@@ -442,7 +535,7 @@ public class MyAppController {
|
|||||||
registration.setIpWhitelistFromString(ipWhitelist);
|
registration.setIpWhitelistFromString(ipWhitelist);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
return new ModelAndView("redirect:/clients/register/step2");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
setupStepModel(model, 1);
|
setupStepModel(model, 1);
|
||||||
@@ -507,7 +600,7 @@ public class MyAppController {
|
|||||||
// 1단계가 완료되었는지 검증
|
// 1단계가 완료되었는지 검증
|
||||||
if (!registration.isStep1Complete()) {
|
if (!registration.isStep1Complete()) {
|
||||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
return new ModelAndView("redirect:/clients/register/step1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
|
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
|
||||||
@@ -538,7 +631,7 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1단계로 리다이렉트
|
// 1단계로 리다이렉트
|
||||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
return new ModelAndView("redirect:/clients/register/step1");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -556,22 +649,16 @@ public class MyAppController {
|
|||||||
// 1단계가 완료되었는지 검증
|
// 1단계가 완료되었는지 검증
|
||||||
if (!registration.isStep1Complete()) {
|
if (!registration.isStep1Complete()) {
|
||||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
return new ModelAndView("redirect:/clients/register/step1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 선택 검증
|
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
|
||||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
registration.setSelectedApis(selectedApis != null ? selectedApis : new ArrayList<>());
|
||||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
|
||||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선택된 API를 세션에 저장
|
|
||||||
registration.setSelectedApis(selectedApis);
|
|
||||||
|
|
||||||
// 등록이 완료되었는지 최종 검증
|
// 등록이 완료되었는지 최종 검증
|
||||||
if (!registration.isComplete()) {
|
if (!registration.isComplete()) {
|
||||||
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
|
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
|
||||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
return new ModelAndView("redirect:/clients/register/step1");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
@@ -588,12 +675,12 @@ public class MyAppController {
|
|||||||
// 성공적으로 완료된 후 세션 초기화
|
// 성공적으로 완료된 후 세션 초기화
|
||||||
sessionStatus.setComplete();
|
sessionStatus.setComplete();
|
||||||
|
|
||||||
return new ModelAndView("redirect:/myapikey/register/step3");
|
return new ModelAndView("redirect:/clients/register/step3");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||||
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
|
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
return new ModelAndView("redirect:/clients/register/step2");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +698,7 @@ public class MyAppController {
|
|||||||
|
|
||||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||||
if (registrationSuccess == null || !registrationSuccess) {
|
if (registrationSuccess == null || !registrationSuccess) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 결과 페이지 표시용 속성 설정
|
// 결과 페이지 표시용 속성 설정
|
||||||
@@ -630,7 +717,7 @@ public class MyAppController {
|
|||||||
public String cancelRegistration(SessionStatus sessionStatus) {
|
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||||
// 등록과 관련된 세션 데이터 초기화
|
// 등록과 관련된 세션 데이터 초기화
|
||||||
sessionStatus.setComplete();
|
sessionStatus.setComplete();
|
||||||
return "redirect:/myapikey";
|
return "redirect:/clients";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -674,13 +761,15 @@ public class MyAppController {
|
|||||||
@Secured("ROLE_API_KEY_REQUEST")
|
@Secured("ROLE_API_KEY_REQUEST")
|
||||||
public ModelAndView modifyStep1(
|
public ModelAndView modifyStep1(
|
||||||
@RequestParam(value = "clientId", required = false) String clientId,
|
@RequestParam(value = "clientId", required = false) String clientId,
|
||||||
|
@RequestParam(value = "goto", required = false) String gotoStep,
|
||||||
|
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
|
||||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||||
SessionStatus sessionStatus,
|
SessionStatus sessionStatus,
|
||||||
Model model,
|
Model model,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
if (clientId == null) {
|
if (clientId == null) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
@@ -688,7 +777,7 @@ public class MyAppController {
|
|||||||
// 기존 API Key 정보 조회
|
// 기존 API Key 정보 조회
|
||||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
|
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
|
||||||
if (apiKey == null) {
|
if (apiKey == null) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로운 수정 세션 시작시에만 초기화
|
// 새로운 수정 세션 시작시에만 초기화
|
||||||
@@ -730,6 +819,16 @@ public class MyAppController {
|
|||||||
model.addAttribute("apiKeyModification", modification);
|
model.addAttribute("apiKeyModification", modification);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
|
||||||
|
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
|
||||||
|
if ("apis".equals(gotoStep)) {
|
||||||
|
String redirectUrl = "redirect:/clients/modify/step2";
|
||||||
|
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
|
||||||
|
redirectUrl += "?apiApplyToast=" + apiApplyToast;
|
||||||
|
}
|
||||||
|
return new ModelAndView(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 모델 설정
|
// Step 모델 설정
|
||||||
setupStepModel(model, 1);
|
setupStepModel(model, 1);
|
||||||
model.addAttribute("userOrg", user.getPortalOrg());
|
model.addAttribute("userOrg", user.getPortalOrg());
|
||||||
@@ -781,7 +880,7 @@ public class MyAppController {
|
|||||||
modification.setIpWhitelistFromString(ipWhitelist);
|
modification.setIpWhitelistFromString(ipWhitelist);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
return new ModelAndView("redirect:/clients/modify/step2");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
setupStepModel(model, 1);
|
setupStepModel(model, 1);
|
||||||
@@ -803,7 +902,7 @@ public class MyAppController {
|
|||||||
// 1단계가 완료되었는지 검증
|
// 1단계가 완료되었는지 검증
|
||||||
if (!modification.isStep1Complete()) {
|
if (!modification.isStep1Complete()) {
|
||||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 서비스 카테고리와 API 목록 가져오기
|
// 서비스 카테고리와 API 목록 가져오기
|
||||||
@@ -813,6 +912,8 @@ public class MyAppController {
|
|||||||
setupStepModel(model, 2);
|
setupStepModel(model, 2);
|
||||||
model.addAttribute("apiServices", apiServices);
|
model.addAttribute("apiServices", apiServices);
|
||||||
model.addAttribute("modification", modification);
|
model.addAttribute("modification", modification);
|
||||||
|
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
|
||||||
|
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
|
||||||
|
|
||||||
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
||||||
}
|
}
|
||||||
@@ -834,7 +935,7 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1단계로 리다이렉트
|
// 1단계로 리다이렉트
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -847,18 +948,19 @@ public class MyAppController {
|
|||||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||||
SessionStatus sessionStatus,
|
SessionStatus sessionStatus,
|
||||||
|
HttpSession session,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
// 1단계가 완료되었는지 검증
|
// 1단계가 완료되었는지 검증
|
||||||
if (!modification.isStep1Complete()) {
|
if (!modification.isStep1Complete()) {
|
||||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 선택 검증
|
// API 선택 검증
|
||||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
return new ModelAndView("redirect:/clients/modify/step2");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 API를 세션에 저장
|
// 선택된 API를 세션에 저장
|
||||||
@@ -867,7 +969,15 @@ public class MyAppController {
|
|||||||
// 등록이 완료되었는지 최종 검증
|
// 등록이 완료되었는지 최종 검증
|
||||||
if (!modification.isComplete()) {
|
if (!modification.isComplete()) {
|
||||||
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
|
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||||
|
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
|
||||||
|
if (isAppModifyTwofaRequired()
|
||||||
|
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||||
|
return new ModelAndView("redirect:/clients/modify/step2");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
@@ -884,12 +994,12 @@ public class MyAppController {
|
|||||||
// 성공적으로 완료된 후 세션 초기화
|
// 성공적으로 완료된 후 세션 초기화
|
||||||
sessionStatus.setComplete();
|
sessionStatus.setComplete();
|
||||||
|
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step3");
|
return new ModelAndView("redirect:/clients/modify/step3");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||||
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
|
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
return new ModelAndView("redirect:/clients/modify/step2");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,7 +1016,7 @@ public class MyAppController {
|
|||||||
|
|
||||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||||
if (modificationComplete == null || !modificationComplete) {
|
if (modificationComplete == null || !modificationComplete) {
|
||||||
return new ModelAndView("redirect:/myapikey");
|
return new ModelAndView("redirect:/clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 결과 페이지 표시용 속성 설정
|
// 결과 페이지 표시용 속성 설정
|
||||||
@@ -935,12 +1045,18 @@ public class MyAppController {
|
|||||||
|
|
||||||
// clientId가 있으면 상세 페이지로, 없으면 목록으로
|
// clientId가 있으면 상세 페이지로, 없으면 목록으로
|
||||||
if (clientId != null && !clientId.isEmpty()) {
|
if (clientId != null && !clientId.isEmpty()) {
|
||||||
return "redirect:/myapikey/credential_detail?id=" + clientId;
|
return "redirect:/clients/credential_detail?id=" + clientId;
|
||||||
} else {
|
} else {
|
||||||
return "redirect:/myapikey";
|
return "redirect:/clients";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
|
||||||
|
private boolean isAppModifyTwofaRequired() {
|
||||||
|
return twoFactorProperties.isStepUpEnabled()
|
||||||
|
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ public class ApiKeyRegistrationDTO implements Serializable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isComplete() {
|
public boolean isComplete() {
|
||||||
return isStep1Complete() && isStep2Complete();
|
// API 선택은 선택 사항이므로 기본 정보(Step1)만 완료되면 등록 가능하다.
|
||||||
|
return isStep1Complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import javax.validation.constraints.NotEmpty;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@@ -25,8 +24,7 @@ public class AppRequestDTO {
|
|||||||
|
|
||||||
private ApprovalDTO approval;
|
private ApprovalDTO approval;
|
||||||
|
|
||||||
@NotEmpty
|
private String apiList = ""; //comma separated api id list (미선택 허용)
|
||||||
private String apiList = ""; //comma separated api id list
|
|
||||||
|
|
||||||
private String apiGroupList = ""; //comma separated api group id list
|
private String apiGroupList = ""; //comma separated api group id list
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.eactive.apim.portal.apps.app.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||||
|
*
|
||||||
|
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||||
|
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminGatewayClient {
|
||||||
|
|
||||||
|
private static final String PROP_GROUP = "Portal";
|
||||||
|
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||||
|
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||||
|
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||||
|
*
|
||||||
|
* @param clientId 차단할 클라이언트 ID
|
||||||
|
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||||
|
*/
|
||||||
|
public void blockClient(String clientId) {
|
||||||
|
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||||
|
|
||||||
|
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
|
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||||
|
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
|
|
||||||
|
Map<?, ?> body = response.getBody();
|
||||||
|
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||||
|
if (!success) {
|
||||||
|
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||||
|
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -59,21 +60,47 @@ public class AppServiceFacade {
|
|||||||
private final ApiServiceHelper apiServiceHelper;
|
private final ApiServiceHelper apiServiceHelper;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AdminGatewayClient adminGatewayClient;
|
||||||
|
|
||||||
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
||||||
|
|
||||||
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
|
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
|
||||||
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
|
// 최근 수정(발급/변경) 순으로 정렬. 수정일 없는 건은 뒤로.
|
||||||
|
return clients.stream()
|
||||||
|
.sorted(Comparator.comparing(Credential::getModifiedon,
|
||||||
|
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||||
|
.map(credentialMapper::toVo).collect(Collectors.toList());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
||||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
|
List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
|
||||||
|
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||||
|
|
||||||
|
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
|
||||||
|
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||||
|
|
||||||
|
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
|
||||||
|
appRequests.sort(Comparator.comparingInt(this::pendingStatusRank)
|
||||||
|
.thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
|
|
||||||
return appRequests;
|
return appRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int pendingStatusRank(AppRequest request) {
|
||||||
|
if (request.getApproval() == null) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (request.getApproval().getApprovalStatus() instanceof ProcessingState) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (request.getApproval().getApprovalStatus() instanceof RequestedState) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
public ClientDTO getApiKey(String orgid, String clientId) {
|
public ClientDTO getApiKey(String orgid, String clientId) {
|
||||||
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
|
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
|
||||||
}
|
}
|
||||||
@@ -133,8 +160,81 @@ public class AppServiceFacade {
|
|||||||
approvalService.beginApproval(approvalId);
|
approvalService.beginApproval(approvalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 이용 해지(DELETE) 결재 신청을 생성하고 결재를 개시합니다.
|
||||||
|
* GW 차단/삭제와 PTL_CREDENTIAL 삭제는 여기서 하지 않으며,
|
||||||
|
* eapim-admin 승인 시점에 PortalAppApprovalListener 가 수행합니다.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException 중복 신청, 변경 신청 진행 중, 승인라인 미등록 등 사용자에게 안내할 상황
|
||||||
|
*/
|
||||||
|
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
|
||||||
|
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
|
||||||
|
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||||
|
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||||
|
for (AppRequest r : related) {
|
||||||
|
if (r.getApproval() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||||
|
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
|
||||||
|
if (!inProgress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (AppRequestType.DELETE.equals(r.getType())) {
|
||||||
|
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
|
||||||
|
AppRequestDTO dto = new AppRequestDTO();
|
||||||
|
dto.setType(AppRequestType.DELETE);
|
||||||
|
dto.setClientId(clientId);
|
||||||
|
dto.setReason(reason);
|
||||||
|
dto.setOrg(portalOrgMapper.toVo(portalOrg));
|
||||||
|
|
||||||
|
AppRequestDTO saved = createAppRequest(dto);
|
||||||
|
|
||||||
|
// 3. 승인라인 미등록이면 approval 이 null — 결재 없는 해지 신청은 만들지 않는다(트랜잭션 롤백)
|
||||||
|
if (saved.getApproval() == null || saved.getApproval().getId() == null) {
|
||||||
|
throw new IllegalStateException("APP 승인라인이 등록되어 있지 않아 해지를 신청할 수 없습니다. 관리자에게 문의해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
beginApproval(saved.getApproval().getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
|
||||||
|
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
|
||||||
|
*/
|
||||||
|
public boolean hasPendingDeleteRequest(String clientId) {
|
||||||
|
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||||
|
clientId, Arrays.asList(AppRequestType.DELETE)).stream()
|
||||||
|
.anyMatch(r -> r.getApproval() != null
|
||||||
|
&& (r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||||
|
|| r.getApproval().getApprovalStatus() instanceof ProcessingState));
|
||||||
|
}
|
||||||
|
|
||||||
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
||||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
|
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
|
||||||
|
if (request.getApproval() == null) {
|
||||||
|
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
|
||||||
|
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
|
||||||
|
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
|
||||||
|
// DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다.
|
||||||
|
if (StringUtils.isNotBlank(request.getClientId())
|
||||||
|
&& !AppRequestType.DELETE.equals(request.getType())) {
|
||||||
|
try {
|
||||||
|
adminGatewayClient.blockClient(request.getClientId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appRequestRepository.delete(request);
|
||||||
|
} else {
|
||||||
|
approvalService.cancelAppApproval(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -164,9 +264,15 @@ public class AppServiceFacade {
|
|||||||
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
||||||
|
|
||||||
for (String apiId : apiList) {
|
for (String apiId : apiList) {
|
||||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
|
||||||
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
||||||
spec.setService(serviceDTO.getGroupName());
|
if (spec == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||||
|
if (serviceDTO != null) {
|
||||||
|
spec.setService(serviceDTO.getGroupName());
|
||||||
|
}
|
||||||
appRequest.getApiSpecList().add(spec);
|
appRequest.getApiSpecList().add(spec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,17 +400,26 @@ public class AppServiceFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key(Credential)를 즉시 삭제합니다.
|
* Client Secret을 1회 조회하고 그 즉시 DB(PTL_CREDENTIAL)에서 물리 삭제합니다.
|
||||||
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
* 보안 정책상 비밀정보는 최초 1회만 제공되며, 조회 후에는 복구할 수 없습니다.
|
||||||
*
|
*
|
||||||
* @param orgId 조직 ID
|
* @param orgId 조직 ID (소유권 확인용)
|
||||||
* @param clientId 삭제할 클라이언트 ID
|
* @param clientId 대상 클라이언트 ID
|
||||||
|
* @return 삭제 전 Client Secret 값. 이미 노출되어 비어있으면 {@code null}
|
||||||
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
|
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
|
||||||
*/
|
*/
|
||||||
public void deleteApp(String orgId, String clientId) {
|
public String revealAndDeleteClientSecret(String orgId, String clientId) {
|
||||||
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||||
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
|
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
|
||||||
|
|
||||||
credentialRepository.delete(credential);
|
String secret = credential.getClientsecret();
|
||||||
|
if (StringUtils.isBlank(secret)) {
|
||||||
|
return null; // 이미 1회 노출되어 삭제됨
|
||||||
|
}
|
||||||
|
|
||||||
|
credential.setClientsecret(null); // 물리 삭제 (1회 제공)
|
||||||
|
credentialRepository.save(credential);
|
||||||
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,21 @@ public class ApprovalDTO {
|
|||||||
private LocalDateTime createdDate;
|
private LocalDateTime createdDate;
|
||||||
|
|
||||||
private LocalDateTime approvalDate;
|
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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증(이메일/SMS) 테스트 안내 관련 PTL_PROPERTY 접근 래퍼.
|
||||||
|
*
|
||||||
|
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
|
||||||
|
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
|
||||||
|
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
|
||||||
|
* {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>prod 프로파일에서는 DB 값과 무관하게 항상 false</b> 를 반환한다(운영 환경 인증번호 노출 금지).
|
||||||
|
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthNoticeProperties {
|
||||||
|
|
||||||
|
public static final String GROUP = "Portal";
|
||||||
|
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
|
||||||
|
* prod 환경에서는 property 값과 무관하게 항상 false.
|
||||||
|
*/
|
||||||
|
public boolean isTestNoticeEnabled() {
|
||||||
|
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
GROUP, KEY_TEST_NOTICE_ENABLED, "true",
|
||||||
|
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||||
|
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ package com.eactive.apim.portal.apps.auth.service;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||||
|
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import lombok.var;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -18,12 +19,12 @@ public class ApiPermissionFilter {
|
|||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
// null 체크 추가
|
// null 체크 추가
|
||||||
var user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
if (user != null && user.getRoleCode() != null) {
|
if (user != null && user.getRoleCode() != null) {
|
||||||
roleCode = user.getRoleCode().toString();
|
roleCode = user.getRoleCode().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
var userOrg = SecurityUtil.getUserOrg();
|
PortalOrgDTO userOrg = SecurityUtil.getUserOrg();
|
||||||
if (userOrg != null) {
|
if (userOrg != null) {
|
||||||
org = userOrg.getId();
|
org = userOrg.getId();
|
||||||
}
|
}
|
||||||
@@ -44,12 +45,12 @@ public class ApiPermissionFilter {
|
|||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
// null 체크 추가
|
// null 체크 추가
|
||||||
var user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
if (user != null && user.getRoleCode() != null) {
|
if (user != null && user.getRoleCode() != null) {
|
||||||
roleCode = user.getRoleCode().toString();
|
roleCode = user.getRoleCode().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
var userOrg = SecurityUtil.getUserOrg();
|
PortalOrgDTO userOrg = SecurityUtil.getUserOrg();
|
||||||
if (userOrg != null) {
|
if (userOrg != null) {
|
||||||
org = userOrg.getId();
|
org = userOrg.getId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,11 @@ public interface AuthNumberService {
|
|||||||
|
|
||||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||||
|
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||||
|
*/
|
||||||
|
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||||
|
|
||||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||||
logger.info("Sending auth number to: {} via {}", recipientKey, msgType);
|
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||||
|
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||||
|
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||||
|
|
||||||
validateResendTime(recipientKey);
|
validateResendTime(recipientKey);
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||||
|
|
||||||
storage.saveAuthNumber(recipientKey, authNumber,
|
storage.saveAuthNumber(recipientKey, authNumber,
|
||||||
LocalDateTime.now().plusSeconds(authNumberExpirationTime));
|
LocalDateTime.now().plusSeconds(ttlSeconds));
|
||||||
|
|
||||||
return authNumber;
|
return authNumber;
|
||||||
}
|
}
|
||||||
@@ -66,17 +72,20 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
logger.info("Verifying auth number for: {}", recipientKey);
|
logger.info("Verifying auth number for: {}", recipientKey);
|
||||||
|
|
||||||
TwoFactorAuth storedAuth = storage.getAuthNumber(recipientKey)
|
TwoFactorAuth storedAuth = storage.getAuthNumber(recipientKey)
|
||||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요."));
|
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요.",
|
||||||
|
AuthNumberException.Reason.NOT_FOUND));
|
||||||
|
|
||||||
if (storedAuth.getExpiresAt().isBefore(LocalDateTime.now())) {
|
if (storedAuth.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||||
storage.deleteAuthNumber(recipientKey);
|
storage.deleteAuthNumber(recipientKey);
|
||||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
|
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.",
|
||||||
|
AuthNumberException.Reason.EXPIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authNumber.equals(storedAuth.getAuthNumber())) {
|
if (authNumber.equals(storedAuth.getAuthNumber())) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.");
|
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.",
|
||||||
|
AuthNumberException.Reason.MISMATCH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.entity.TwoFactorAuth;
|
||||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
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.time.LocalDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class AuthNumberStorage {
|
public class AuthNumberStorage {
|
||||||
|
|
||||||
|
private static final String HASH_ALGORITHM = "HmacSHA256";
|
||||||
|
|
||||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
|
||||||
|
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
|
||||||
|
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
|
||||||
|
*/
|
||||||
|
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||||
|
private String hashPepper;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
||||||
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
|
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);
|
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
|
||||||
|
auth.setRecipientKey(lookupKey);
|
||||||
twoFactorAuthRepository.save(auth);
|
twoFactorAuthRepository.save(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
||||||
return twoFactorAuthRepository.findByRecipient(recipientKey);
|
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteAuthNumber(String 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,80 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* step-up 2FA(민감기능 추가 인증) 진입 가드.
|
||||||
|
*
|
||||||
|
* <p>{@link StepUpProtectedPaths#isInterceptorGuarded(String)} 경로 진입 시, 유효한 1회용
|
||||||
|
* 통과권이 없으면 2FA 를 요구한다.
|
||||||
|
* <ul>
|
||||||
|
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
|
||||||
|
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
|
||||||
|
* </ul>
|
||||||
|
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
|
||||||
|
*
|
||||||
|
* <p>내 정보 변경({@code /mypage}, PASSWORD 레벨)과 비밀번호 반영({@code POST /password/change},
|
||||||
|
* 반영 직전 2FA)은 각 컨트롤러가 직접 관장하므로 이 인터셉터 대상이 아니다.</p>
|
||||||
|
*/
|
||||||
|
public class StepUpAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
|
||||||
|
public StepUpAuthInterceptor(TwoFactorService twoFactorService, TwoFactorProperties twoFactorProperties) {
|
||||||
|
this.twoFactorService = twoFactorService;
|
||||||
|
this.twoFactorProperties = twoFactorProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
|
if (!twoFactorProperties.isStepUpEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String path = request.getServletPath();
|
||||||
|
if (!StepUpProtectedPaths.isInterceptorGuarded(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
|
||||||
|
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpSession session = request.getSession(false);
|
||||||
|
if (session == null) {
|
||||||
|
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1회용 통과권 소비 시도 (매번 인증: 있으면 소멸 후 통과)
|
||||||
|
if (twoFactorService.consumeStepUpPass(session, path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("POST".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
// AJAX 지점(Secret 조회/앱 해지) → 프론트가 팝업을 띄우도록 신호
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"stepUpRequired\":true}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 페이지 진입 → 챌린지 페이지로 유도(원경로+쿼리 보존)
|
||||||
|
String returnUrl = path;
|
||||||
|
String query = request.getQueryString();
|
||||||
|
if (query != null && !query.isEmpty()) {
|
||||||
|
returnUrl = returnUrl + "?" + query;
|
||||||
|
}
|
||||||
|
String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8.name());
|
||||||
|
response.sendRedirect(request.getContextPath() + "/auth/2fa/challenge?returnUrl=" + encoded);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.access.annotation.Secured;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완화된 step-up(PASSWORD 레벨) 확인 페이지.
|
||||||
|
*
|
||||||
|
* <p>{@link StepUpProtectedPaths#isPasswordGated(String)} 경로(예: {@code /mypage})는 2FA 대신
|
||||||
|
* <b>현재 비밀번호 재확인</b>만 요구한다. 각 컨트롤러가 통과권이 없을 때 이 페이지로 유도하고,
|
||||||
|
* 확인 성공 시 해당 경로의 통과권을 발급한 뒤 원경로로 복귀시킨다.</p>
|
||||||
|
*
|
||||||
|
* <p>returnUrl 은 PASSWORD 레벨 화이트리스트로만 검증·복귀하여 open redirect 를 막는다.</p>
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@Secured("ROLE_ACCOUNT")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/auth/stepup")
|
||||||
|
public class StepUpPasswordController {
|
||||||
|
|
||||||
|
private final UserFacade userFacade;
|
||||||
|
private final TwoFactorService twoFactorService;
|
||||||
|
|
||||||
|
@GetMapping("/password")
|
||||||
|
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||||
|
String path = pathOf(returnUrl);
|
||||||
|
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||||
|
return "redirect:/";
|
||||||
|
}
|
||||||
|
model.addAttribute("returnUrl", path);
|
||||||
|
return "apps/auth/stepupPassword";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/password")
|
||||||
|
public String verify(@RequestParam String currentPassword,
|
||||||
|
@RequestParam(required = false) String returnUrl,
|
||||||
|
HttpSession session, Model model) {
|
||||||
|
String path = pathOf(returnUrl);
|
||||||
|
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||||
|
return "redirect:/";
|
||||||
|
}
|
||||||
|
|
||||||
|
String loginId = SecurityUtil.getCurrentLoginId();
|
||||||
|
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||||
|
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||||
|
twoFactorService.grantStepUpPass(session, path);
|
||||||
|
return "redirect:" + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||||
|
model.addAttribute("returnUrl", path);
|
||||||
|
return "apps/auth/stepupPassword";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
|
||||||
|
private static String pathOf(String url) {
|
||||||
|
if (url == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int q = url.indexOf('?');
|
||||||
|
return q >= 0 ? url.substring(0, q) : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* step-up(민감기능 추가 인증) 대상 서블릿 경로 화이트리스트 + 지점별 프로퍼티 키 + 검증 레벨.
|
||||||
|
*
|
||||||
|
* <p>검증 레벨(완화 정책)</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
||||||
|
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
|
||||||
|
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
|
||||||
|
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
||||||
|
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>세 가지 관심사를 분리한다.</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link #isTwoFactorPurpose(String)} — 공통 2FA 팝업의 유효 대상(purpose) 화이트리스트.
|
||||||
|
* open redirect / 임의 purpose 로의 2FA 발송·통과권 발급을 막는 데 쓴다.</li>
|
||||||
|
* <li>{@link #isInterceptorGuarded(String)} — {@link StepUpAuthInterceptor} 가 진입 시점에
|
||||||
|
* 자동 차단하는 경로. 비밀번호 변경(반영 직전 확인)·내 정보(별도 확인 페이지)는
|
||||||
|
* 각 컨트롤러가 직접 관장하므로 여기서 제외한다.</li>
|
||||||
|
* <li>{@link #isPasswordGated(String)} — 현재 비밀번호 재확인으로 보호하는 경로.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어하며
|
||||||
|
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
|
||||||
|
*/
|
||||||
|
public final class StepUpProtectedPaths {
|
||||||
|
|
||||||
|
/** step-up 검증 레벨(완화 정책) */
|
||||||
|
public enum Level {
|
||||||
|
/** 공통 2FA 팝업(인증번호) */
|
||||||
|
TWO_FACTOR,
|
||||||
|
/** 현재 비밀번호 재확인만 */
|
||||||
|
PASSWORD
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Secret 키 조회 (AJAX POST) */
|
||||||
|
public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
|
||||||
|
/** 앱 해지 신청 (AJAX POST) */
|
||||||
|
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
|
||||||
|
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
|
||||||
|
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
|
||||||
|
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
||||||
|
public static final String MYPAGE = "/mypage";
|
||||||
|
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
||||||
|
public static final String PASSWORD_CHANGE = "/password/change";
|
||||||
|
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
|
||||||
|
public static final String WITHDRAW = "/withdraw";
|
||||||
|
|
||||||
|
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||||
|
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||||
|
|
||||||
|
/** 경로 → 지점별 프로퍼티 키. 삽입 순서 유지(LinkedHashMap) */
|
||||||
|
private static final Map<String, String> PATH_TO_KEY;
|
||||||
|
/** 경로 → 검증 레벨 */
|
||||||
|
private static final Map<String, Level> PATH_TO_LEVEL;
|
||||||
|
/** 인터셉터가 진입 시점에 자동 차단하는 경로(2FA) */
|
||||||
|
private static final Set<String> INTERCEPTOR_GUARDED;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, String> keys = new LinkedHashMap<>();
|
||||||
|
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
|
||||||
|
keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
|
||||||
|
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
|
||||||
|
keys.put(MYPAGE, KEY_PREFIX + "mypage");
|
||||||
|
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||||
|
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
|
||||||
|
PATH_TO_KEY = Collections.unmodifiableMap(keys);
|
||||||
|
|
||||||
|
Map<String, Level> levels = new LinkedHashMap<>();
|
||||||
|
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
|
||||||
|
levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
|
||||||
|
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
|
||||||
|
levels.put(MYPAGE, Level.PASSWORD);
|
||||||
|
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
||||||
|
levels.put(WITHDRAW, Level.TWO_FACTOR);
|
||||||
|
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
||||||
|
|
||||||
|
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
||||||
|
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
||||||
|
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
|
||||||
|
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
|
||||||
|
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
||||||
|
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
|
||||||
|
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
|
||||||
|
Set<String> guarded = new java.util.LinkedHashSet<>();
|
||||||
|
guarded.add(REVEAL_SECRET);
|
||||||
|
guarded.add(APP_KEY_DELETE);
|
||||||
|
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StepUpProtectedPaths() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 공통 2FA 팝업의 유효 대상(purpose)인지 — open redirect / 임의 purpose 차단용 */
|
||||||
|
public static boolean isTwoFactorPurpose(String servletPath) {
|
||||||
|
return servletPath != null
|
||||||
|
&& PATH_TO_LEVEL.get(servletPath) == Level.TWO_FACTOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 인터셉터가 진입 시점에 자동 차단하는 경로인지 */
|
||||||
|
public static boolean isInterceptorGuarded(String servletPath) {
|
||||||
|
return servletPath != null && INTERCEPTOR_GUARDED.contains(servletPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 비밀번호 재확인으로 보호하는 경로인지(PASSWORD 레벨) */
|
||||||
|
public static boolean isPasswordGated(String servletPath) {
|
||||||
|
return servletPath != null && PATH_TO_LEVEL.get(servletPath) == Level.PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 해당 경로의 지점별 활성화 프로퍼티 키. 대상 경로가 아니면 null */
|
||||||
|
public static String propertyKeyOf(String servletPath) {
|
||||||
|
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지점별 프로퍼티 키 접두 */
|
||||||
|
public static String keyPrefix() {
|
||||||
|
return KEY_PREFIX;
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 만료된 2FA 인증번호(PTL_TWO_FACTOR_AUTH) 정리 스케줄러.
|
||||||
|
*
|
||||||
|
* <p>검증은 접근 시점 lazy 만료 검사만 하므로, 발송 후 검증 없이 방치된 레코드가 남는다.
|
||||||
|
* 1분 주기로 만료분을 일괄 삭제한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>다중화(스케일아웃) 안전성:</b> 작업이 "만료된 행만" 지우는 멱등 delete 라
|
||||||
|
* 여러 인스턴스가 동시에 실행해도 결과가 동일하고 부작용이 없다. 따라서 분산 락
|
||||||
|
* (ShedLock 등)이 필요 없다. 동일 행을 둘이 지우려 하면 한쪽이 0건 삭제로 끝날 뿐이다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TwoFactorCleanupScheduler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TwoFactorCleanupScheduler.class);
|
||||||
|
|
||||||
|
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||||
|
|
||||||
|
@Scheduled(fixedRate = 60000)
|
||||||
|
@Transactional
|
||||||
|
public void cleanupExpired() {
|
||||||
|
try {
|
||||||
|
int deleted = twoFactorAuthRepository.deleteAllByExpiresAtBefore(LocalDateTime.now());
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.debug("만료된 2FA 인증번호 {}건 정리", deleted);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("2FA 인증번호 정리 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행 중인 2FA 절차 상태. HTTP 세션에 단일 소스로 보관한다.
|
||||||
|
*
|
||||||
|
* <p>세션 클러스터링(stage/prod Redis/Ehcache) 대상이므로 {@link Serializable} 이다.
|
||||||
|
* 여러 탭/페이지에서 동시에 2FA 가 발동되지 않도록, 발송 시 이 컨텍스트 존재 여부로
|
||||||
|
* "진행 중" 을 판정하고 confirm 후 강제 종료(force)로만 새 절차를 시작한다.</p>
|
||||||
|
*/
|
||||||
|
public class TwoFactorContext implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public enum Mode {
|
||||||
|
/** 로그인 1차 인증 통과 후 대기(pending) 상태의 2FA */
|
||||||
|
LOGIN,
|
||||||
|
/** 로그인 이후 민감기능 접근 시 추가 인증(step-up) */
|
||||||
|
STEPUP
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mode mode;
|
||||||
|
/** 발송 채널 (EMAIL | SMS) */
|
||||||
|
private String channel;
|
||||||
|
/** AuthNumberService 에 전달한 실제 수신처 문자열(이메일 소문자 / 휴대폰 digits). 검증 시 동일 값 사용 */
|
||||||
|
private String recipient;
|
||||||
|
/** step-up 대상 보호 경로(purpose). LOGIN 모드에서는 null */
|
||||||
|
private String purpose;
|
||||||
|
/** 발송 시각 */
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
/** 유효시간(초) */
|
||||||
|
private int ttlSeconds;
|
||||||
|
/** 검증 시도 횟수 */
|
||||||
|
private int attempts;
|
||||||
|
|
||||||
|
public Mode getMode() {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMode(Mode mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getChannel() {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannel(String channel) {
|
||||||
|
this.channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecipient() {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipient(String recipient) {
|
||||||
|
this.recipient = recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPurpose() {
|
||||||
|
return purpose;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPurpose(String purpose) {
|
||||||
|
this.purpose = purpose;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getStartedAt() {
|
||||||
|
return startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartedAt(LocalDateTime startedAt) {
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTtlSeconds() {
|
||||||
|
return ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTtlSeconds(int ttlSeconds) {
|
||||||
|
this.ttlSeconds = ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAttempts() {
|
||||||
|
return attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAttempts(int attempts) {
|
||||||
|
this.attempts = attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int incrementAttempts() {
|
||||||
|
return ++this.attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** startedAt + ttl 기준 만료 여부(세션 컨텍스트 lazy 만료 판정용) */
|
||||||
|
public boolean isExpired(LocalDateTime now) {
|
||||||
|
return startedAt == null || startedAt.plusSeconds(ttlSeconds).isBefore(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 2FA 팝업 백엔드. 로그인 pending·step-up 을 모두 처리한다.
|
||||||
|
*
|
||||||
|
* <p>수신처는 서버가 세션 대상 사용자로부터 결정하므로 클라이언트는 채널만 전달한다.
|
||||||
|
* 모든 POST 는 세션 기반 CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/auth/2fa")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TwoFactorController {
|
||||||
|
|
||||||
|
private final TwoFactorService twoFactorService;
|
||||||
|
|
||||||
|
/** 팝업 초기 정보(채널·TTL·진행중 여부) */
|
||||||
|
@GetMapping("/info")
|
||||||
|
@ResponseBody
|
||||||
|
public TwoFactorInfoResponse info(@RequestParam(required = false) String purpose, HttpSession session) {
|
||||||
|
return twoFactorService.getInfo(session, purpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 인증번호 발송 */
|
||||||
|
@PostMapping("/send")
|
||||||
|
@ResponseBody
|
||||||
|
public TwoFactorSendResponse send(@RequestParam String channel,
|
||||||
|
@RequestParam(required = false) String purpose,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") boolean force,
|
||||||
|
HttpSession session) {
|
||||||
|
return twoFactorService.send(session, channel, purpose, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 인증번호 검증 */
|
||||||
|
@PostMapping("/verify")
|
||||||
|
@ResponseBody
|
||||||
|
public TwoFactorVerifyResponse verify(@RequestParam String code,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpSession session) {
|
||||||
|
return twoFactorService.verify(request, session, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업 닫기/타이머 만료 → 2차 인증 실패 처리 */
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
@ResponseBody
|
||||||
|
public void cancel(@RequestParam(required = false, defaultValue = "CANCELLED") String reason,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpSession session) {
|
||||||
|
twoFactorService.cancel(request, session, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* step-up GET 진입 지점용 챌린지 페이지. 인터셉터가 리다이렉트하며, 화면이 공통 팝업을 자동 오픈한다.
|
||||||
|
* returnUrl 은 보호 경로 화이트리스트로 검증(open redirect 방지)한다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/challenge")
|
||||||
|
public String challenge(@RequestParam(required = false) String returnUrl, Model model) {
|
||||||
|
// returnUrl 은 쿼리스트링을 포함할 수 있으므로 경로 부분만 화이트리스트로 검증(open redirect 방지)
|
||||||
|
String purpose = pathOf(returnUrl);
|
||||||
|
if (!StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||||
|
return "redirect:/";
|
||||||
|
}
|
||||||
|
model.addAttribute("returnUrl", returnUrl);
|
||||||
|
model.addAttribute("purpose", purpose);
|
||||||
|
return "apps/auth/twoFactorChallenge";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pathOf(String url) {
|
||||||
|
if (url == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int q = url.indexOf('?');
|
||||||
|
return q >= 0 ? url.substring(0, q) : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2FA 관련 PTL_PROPERTY 접근 래퍼.
|
||||||
|
*
|
||||||
|
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례({@code session.timeout.minutes},
|
||||||
|
* {@code org.hard-delete.enabled} 등)를 따른다.
|
||||||
|
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
|
||||||
|
* 생성(그룹 존재 시)하므로 별도 초기 데이터가 필요 없다. 캐시가 없어 매 호출 DB 조회지만
|
||||||
|
* 2FA 진입 경로가 제한적이라 허용 범위다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TwoFactorProperties {
|
||||||
|
|
||||||
|
public static final String GROUP = "Portal";
|
||||||
|
|
||||||
|
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
|
||||||
|
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
|
||||||
|
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||||
|
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||||
|
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||||
|
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 로그인 2FA 활성화 여부 */
|
||||||
|
public boolean isLoginEnabled() {
|
||||||
|
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 2FA 적용 대상 역할인지 여부.
|
||||||
|
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
|
||||||
|
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
|
||||||
|
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
|
||||||
|
*/
|
||||||
|
public boolean isLoginTargetRole(RoleCode roleCode) {
|
||||||
|
if (roleCode == null) {
|
||||||
|
roleCode = RoleCode.ROLE_USER;
|
||||||
|
}
|
||||||
|
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
|
||||||
|
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
if ("ALL".equalsIgnoreCase(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String token : trimmed.split(",")) {
|
||||||
|
if (roleCode.name().equalsIgnoreCase(token.trim())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
|
||||||
|
public boolean isStepUpEnabled() {
|
||||||
|
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 보호 경로에 step-up 2FA 를 적용할지 여부(지점별 스위치).
|
||||||
|
* 전체 스위치({@link #isStepUpEnabled()})가 켜진 상태에서 지점별로 개별 on/off 한다.
|
||||||
|
* 지점 프로퍼티({@code two-factor.stepup.<지점>})의 기본값은 true(전체 스위치를 켜면 기본 전 지점 적용).
|
||||||
|
*
|
||||||
|
* @param servletPath 보호 경로. 매핑 키가 없으면(비보호 경로) false
|
||||||
|
*/
|
||||||
|
public boolean isStepUpPointEnabled(String servletPath) {
|
||||||
|
String key = StepUpProtectedPaths.propertyKeyOf(servletPath);
|
||||||
|
if (key == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return parseBool(resolve(key, "true", "step-up 2FA 지점 적용 여부 (true/false): " + servletPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2FA 인증번호 유효시간(초). 기본 180초(3분) */
|
||||||
|
public int getTtlSeconds() {
|
||||||
|
return parseInt(resolve(KEY_TTL_SECONDS, "180", "2차 인증번호 유효시간(초)"), 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 인증번호 검증 시도 한도. 기본 5회 */
|
||||||
|
public int getAttemptLimit() {
|
||||||
|
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
||||||
|
public boolean isTestNoticeEnabled() {
|
||||||
|
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolve(String key, String defaultValue, String description) {
|
||||||
|
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* boolean PTL_PROPERTY 값 파싱.
|
||||||
|
* DB 관례에 맞춰 <b>true/false</b> 문자열을 사용한다(예: {@code org.hard-delete.enabled=true}).
|
||||||
|
* "true"(대소문자 무시)만 참으로 본다. 그 외(false/공백/null 등)는 모두 거짓.
|
||||||
|
*/
|
||||||
|
private static boolean parseBool(String value) {
|
||||||
|
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseInt(String value, int fallback) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
|
||||||
|
import com.eactive.apim.portal.apps.auth.service.AuthNumberStorage;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorChannel;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||||
|
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||||
|
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||||
|
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2FA(2차 인증/추가 인증) 공통 서비스. 로그인 pending 인증과 step-up(민감기능) 인증을 모두 처리한다.
|
||||||
|
*
|
||||||
|
* <p>핵심 원칙:
|
||||||
|
* <ul>
|
||||||
|
* <li>수신처는 서버가 세션의 대상 사용자로부터 DB 기준으로 결정한다(클라이언트는 채널만 선택).</li>
|
||||||
|
* <li>진행 상태는 세션 {@link TwoFactorContext} 단일 소스로 관리한다.</li>
|
||||||
|
* <li>다른 플로우가 진행 중이면 발송을 막고(inProgress), confirm 후 force 로만 강제 종료·재시작한다.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TwoFactorService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TwoFactorService.class);
|
||||||
|
|
||||||
|
// === 세션 attribute 키 ===
|
||||||
|
/** 로그인 1차 인증 통과 후 대기 중인 사용자 id (존재 시 LOGIN 모드) */
|
||||||
|
public static final String ATTR_PENDING_USER_ID = "TFA_PENDING_USER_ID";
|
||||||
|
/** 대기 중 사용자 loginId (감사/세션 표기) */
|
||||||
|
public static final String ATTR_PENDING_LOGIN_ID = "TFA_PENDING_LOGIN_ID";
|
||||||
|
/** 진행 중 2FA 컨텍스트 */
|
||||||
|
public static final String ATTR_CONTEXT = "TFA_CONTEXT";
|
||||||
|
/** step-up 1회용 통과권 - 대상 경로 */
|
||||||
|
public static final String ATTR_STEPUP_PASS_PATH = "TFA_STEPUP_PASS_PATH";
|
||||||
|
/** step-up 1회용 통과권 - 발급 시각 */
|
||||||
|
public static final String ATTR_STEPUP_PASS_AT = "TFA_STEPUP_PASS_AT";
|
||||||
|
|
||||||
|
/** step-up 통과권 유효시간(초). 인증 성공 후 대상 페이지 진입까지의 이동 여유분 */
|
||||||
|
public static final int STEPUP_PASS_TTL_SECONDS = 120;
|
||||||
|
/** 재발송/채널전환 통합 최소 간격(초) */
|
||||||
|
private static final int RESEND_THROTTLE_SECONDS = 30;
|
||||||
|
|
||||||
|
private final TwoFactorProperties properties;
|
||||||
|
private final AuthNumberService authNumberService;
|
||||||
|
private final AuthNumberStorage authNumberStorage;
|
||||||
|
private final PortalUserRepository portalUserRepository;
|
||||||
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
|
private final PortalUserLogService userLogService;
|
||||||
|
private final LoginFinalizer loginFinalizer;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// INFO
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public TwoFactorInfoResponse getInfo(HttpSession session, String purpose) {
|
||||||
|
TwoFactorInfoResponse res = new TwoFactorInfoResponse();
|
||||||
|
|
||||||
|
TwoFactorContext.Mode mode = resolveMode(session);
|
||||||
|
if (mode == null) {
|
||||||
|
res.setAvailable(false);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
PortalUser user = resolveTargetUser(session, mode);
|
||||||
|
if (user == null) {
|
||||||
|
res.setAvailable(false);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setAvailable(true);
|
||||||
|
res.setMode(mode.name());
|
||||||
|
res.setChannels(buildChannels(user));
|
||||||
|
res.setTtlSeconds(properties.getTtlSeconds());
|
||||||
|
res.setTestNoticeEnabled(properties.isTestNoticeEnabled());
|
||||||
|
|
||||||
|
TwoFactorContext ctx = getActiveContext(session);
|
||||||
|
if (ctx != null && !isSameFlow(ctx, mode, purpose)) {
|
||||||
|
res.setInProgress(true);
|
||||||
|
res.setMessage("진행 중인 다른 인증 절차가 있습니다.");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SEND
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
|
||||||
|
TwoFactorSendResponse res = new TwoFactorSendResponse();
|
||||||
|
|
||||||
|
TwoFactorContext.Mode mode = resolveMode(session);
|
||||||
|
if (mode == null) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setMessage("인증 대상 정보가 없습니다. 다시 시도해주세요.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setMessage("허용되지 않은 요청입니다.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalUser user = resolveTargetUser(session, mode);
|
||||||
|
if (user == null) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setMessage("인증 대상 사용자를 찾을 수 없습니다.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedChannel = channel == null ? "" : channel.trim().toUpperCase();
|
||||||
|
String recipient = resolveRecipient(user, normalizedChannel);
|
||||||
|
if (recipient == null) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setMessage("선택한 방법으로 인증할 수 있는 정보가 없습니다.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 진행 중 컨텍스트 처리
|
||||||
|
TwoFactorContext ctx = getActiveContext(session);
|
||||||
|
if (ctx != null) {
|
||||||
|
boolean sameFlow = isSameFlow(ctx, mode, purpose);
|
||||||
|
if (!sameFlow) {
|
||||||
|
if (!force) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setInProgress(true);
|
||||||
|
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
|
||||||
|
} else if (ctx.getStartedAt() != null
|
||||||
|
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setMessage("잠시 후에 다시 시도해 주세요.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int ttl = properties.getTtlSeconds();
|
||||||
|
String authNumber;
|
||||||
|
try {
|
||||||
|
authNumber = authNumberService.sendRequestAuthNumber(recipient,
|
||||||
|
"SMS".equals(normalizedChannel) ? "SMS" : "EMAIL", ttl);
|
||||||
|
} catch (AuthNumberException e) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setMessage(e.getMessage());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
TwoFactorContext newCtx = new TwoFactorContext();
|
||||||
|
newCtx.setMode(mode);
|
||||||
|
newCtx.setChannel(normalizedChannel);
|
||||||
|
newCtx.setRecipient(recipient);
|
||||||
|
newCtx.setPurpose(mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||||
|
newCtx.setStartedAt(LocalDateTime.now());
|
||||||
|
newCtx.setTtlSeconds(ttl);
|
||||||
|
newCtx.setAttempts(0);
|
||||||
|
session.setAttribute(ATTR_CONTEXT, newCtx);
|
||||||
|
|
||||||
|
res.setValid(true);
|
||||||
|
res.setMessage("인증번호를 발송하였습니다.");
|
||||||
|
res.setTtlSeconds(ttl);
|
||||||
|
if (properties.isTestNoticeEnabled()) {
|
||||||
|
res.setTestAuthNumber(authNumber);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// VERIFY
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public TwoFactorVerifyResponse verify(HttpServletRequest request, HttpSession session, String code) {
|
||||||
|
TwoFactorVerifyResponse res = new TwoFactorVerifyResponse();
|
||||||
|
|
||||||
|
TwoFactorContext ctx = getActiveContext(session);
|
||||||
|
if (ctx == null) {
|
||||||
|
res.setValid(false);
|
||||||
|
res.setTerminated(true);
|
||||||
|
res.setMessage("인증 시간이 만료되었습니다. 처음부터 다시 진행해주세요.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.isExpired(LocalDateTime.now())) {
|
||||||
|
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||||
|
res.setValid(false);
|
||||||
|
res.setTerminated(true);
|
||||||
|
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
int attempts = ctx.incrementAttempts();
|
||||||
|
int limit = properties.getAttemptLimit();
|
||||||
|
|
||||||
|
try {
|
||||||
|
authNumberService.verifyAuthNumber(ctx.getRecipient(), code);
|
||||||
|
} catch (AuthNumberException e) {
|
||||||
|
AuthNumberException.Reason reason = e.getReason();
|
||||||
|
if (reason == AuthNumberException.Reason.EXPIRED || reason == AuthNumberException.Reason.NOT_FOUND) {
|
||||||
|
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||||
|
res.setValid(false);
|
||||||
|
res.setTerminated(true);
|
||||||
|
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// 코드 불일치
|
||||||
|
if (attempts >= limit) {
|
||||||
|
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_ATTEMPT_EXCEEDED, request);
|
||||||
|
res.setValid(false);
|
||||||
|
res.setTerminated(true);
|
||||||
|
res.setMessage("인증 시도 횟수를 초과했습니다. 처음부터 다시 진행해주세요.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
session.setAttribute(ATTR_CONTEXT, ctx); // attempts 갱신 반영
|
||||||
|
res.setValid(false);
|
||||||
|
res.setRemainingAttempts(limit - attempts);
|
||||||
|
res.setMessage("인증번호가 일치하지 않습니다. (남은 횟수 " + (limit - attempts) + "회)");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검증 성공 — 인증번호 즉시 소비(재사용 방지, 2FA 한정)
|
||||||
|
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||||
|
session.removeAttribute(ATTR_CONTEXT);
|
||||||
|
|
||||||
|
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||||
|
return completeLogin(request, session, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEPUP — 1회용 통과권 발급
|
||||||
|
issueStepUpPass(session, ctx.getPurpose());
|
||||||
|
res.setValid(true);
|
||||||
|
res.setMessage("인증이 완료되었습니다.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TwoFactorVerifyResponse completeLogin(HttpServletRequest request, HttpSession session,
|
||||||
|
TwoFactorVerifyResponse res) {
|
||||||
|
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||||
|
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
|
||||||
|
PortalUser user = userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||||
|
if (user == null) {
|
||||||
|
clearPending(session);
|
||||||
|
res.setValid(false);
|
||||||
|
res.setTerminated(true);
|
||||||
|
res.setMessage("로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1차 인증~2FA 사이 상태 변경 방어(잠금/차단/승인 취소)
|
||||||
|
String stateError = revalidateLoginState(user);
|
||||||
|
if (stateError != null) {
|
||||||
|
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||||
|
LoginFailureReason.ACCOUNT_DISABLED);
|
||||||
|
clearPending(session);
|
||||||
|
res.setValid(false);
|
||||||
|
res.setTerminated(true);
|
||||||
|
res.setMessage(stateError);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||||
|
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||||
|
UsernamePasswordAuthenticationToken token =
|
||||||
|
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||||
|
token.setDetails(authUser);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(token);
|
||||||
|
|
||||||
|
String redirect = loginFinalizer.finalizeLogin(user, loginId, request, LoginType.TWO_FACTOR);
|
||||||
|
clearPending(session);
|
||||||
|
|
||||||
|
res.setValid(true);
|
||||||
|
res.setRedirect(redirect);
|
||||||
|
res.setMessage("인증이 완료되었습니다.");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CANCEL (팝업 닫기 / 타이머 만료)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public void cancel(HttpServletRequest request, HttpSession session, String reason) {
|
||||||
|
TwoFactorContext ctx = getActiveContext(session);
|
||||||
|
boolean timeout = "TIMEOUT".equalsIgnoreCase(reason);
|
||||||
|
LoginFailureReason failureReason = timeout
|
||||||
|
? LoginFailureReason.TWO_FACTOR_TIMEOUT : LoginFailureReason.TWO_FACTOR_CANCELLED;
|
||||||
|
|
||||||
|
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||||
|
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason);
|
||||||
|
}
|
||||||
|
if (ctx != null && ctx.getRecipient() != null) {
|
||||||
|
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||||
|
}
|
||||||
|
session.removeAttribute(ATTR_CONTEXT);
|
||||||
|
|
||||||
|
// 로그인 2FA 취소는 로그인 자체를 포기(익명 유지) → pending 제거
|
||||||
|
if (ctx == null || ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||||
|
clearPending(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// LOGIN pending 진입 (SuccessHandler 에서 호출)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 로그인 1차 인증 통과 사용자를 2FA 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||||
|
public void beginLoginChallenge(HttpSession session, PortalUser user) {
|
||||||
|
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||||
|
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||||
|
session.removeAttribute(ATTR_CONTEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasPendingLogin(HttpSession session) {
|
||||||
|
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// STEP-UP 통과권
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void issueStepUpPass(HttpSession session, String path) {
|
||||||
|
session.setAttribute(ATTR_STEPUP_PASS_PATH, path);
|
||||||
|
session.setAttribute(ATTR_STEPUP_PASS_AT, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보호 경로 수정 저장 직후 원경로로 되돌아가는 즉시 왕복(예: {@code /mypage} 수정 →
|
||||||
|
* {@code redirect:/mypage})에서 중복 step-up 을 막기 위해 통과권을 재발급한다.
|
||||||
|
*
|
||||||
|
* <p>경로 고정 + TTL({@link #STEPUP_PASS_TTL_SECONDS}s) 로 <b>1회 왕복만</b> 커버하며,
|
||||||
|
* 이후 새로 {@code /mypage} 에 진입하면 정상적으로 다시 인증을 요구한다("매번 인증" 유지).</p>
|
||||||
|
*/
|
||||||
|
public void grantStepUpPass(HttpSession session, String path) {
|
||||||
|
if (session != null && path != null) {
|
||||||
|
issueStepUpPass(session, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정 경로에 대한 유효한 1회용 통과권이 있으면 소비(제거)하고 true 를 반환한다.
|
||||||
|
* (매번 인증 정책 — 통과권은 즉시 소멸)
|
||||||
|
*/
|
||||||
|
public boolean consumeStepUpPass(HttpSession session, String servletPath) {
|
||||||
|
Object passPath = session.getAttribute(ATTR_STEPUP_PASS_PATH);
|
||||||
|
Object passAt = session.getAttribute(ATTR_STEPUP_PASS_AT);
|
||||||
|
if (!(passPath instanceof String) || !(passAt instanceof LocalDateTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean valid = passPath.equals(servletPath)
|
||||||
|
&& ((LocalDateTime) passAt).plusSeconds(STEPUP_PASS_TTL_SECONDS).isAfter(LocalDateTime.now());
|
||||||
|
// 매번 인증: 일치/불일치 무관하게 통과권은 이번 판정에서 소멸시킨다.
|
||||||
|
session.removeAttribute(ATTR_STEPUP_PASS_PATH);
|
||||||
|
session.removeAttribute(ATTR_STEPUP_PASS_AT);
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 내부 helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private TwoFactorContext.Mode resolveMode(HttpSession session) {
|
||||||
|
if (session.getAttribute(ATTR_PENDING_USER_ID) != null) {
|
||||||
|
return TwoFactorContext.Mode.LOGIN;
|
||||||
|
}
|
||||||
|
if (SecurityUtil.isAuthenticated()) {
|
||||||
|
return TwoFactorContext.Mode.STEPUP;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PortalUser resolveTargetUser(HttpSession session, TwoFactorContext.Mode mode) {
|
||||||
|
if (mode == TwoFactorContext.Mode.LOGIN) {
|
||||||
|
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||||
|
return userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||||
|
}
|
||||||
|
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
if (current == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 세션 로드 이후 연락처 변경 반영을 위해 DB 재조회
|
||||||
|
return portalUserRepository.findById(current.getId()).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TwoFactorChannel> buildChannels(PortalUser user) {
|
||||||
|
List<TwoFactorChannel> channels = new ArrayList<>();
|
||||||
|
if (StringUtils.hasText(user.getEmailAddr())) {
|
||||||
|
channels.add(new TwoFactorChannel("EMAIL", StringMaskingUtil.maskEmail(user.getEmailAddr())));
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(user.getMobileNumber())) {
|
||||||
|
channels.add(new TwoFactorChannel("SMS", StringMaskingUtil.maskMobileNumber(user.getMobileNumber())));
|
||||||
|
}
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRecipient(PortalUser user, String channel) {
|
||||||
|
if ("EMAIL".equals(channel)) {
|
||||||
|
return StringUtils.hasText(user.getEmailAddr()) ? user.getEmailAddr() : null;
|
||||||
|
}
|
||||||
|
if ("SMS".equals(channel)) {
|
||||||
|
return StringUtils.hasText(user.getMobileNumber())
|
||||||
|
? PhoneNumberUtil.digitsOnly(user.getMobileNumber()) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TwoFactorContext getActiveContext(HttpSession session) {
|
||||||
|
Object ctx = session.getAttribute(ATTR_CONTEXT);
|
||||||
|
if (!(ctx instanceof TwoFactorContext)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TwoFactorContext context = (TwoFactorContext) ctx;
|
||||||
|
if (context.isExpired(LocalDateTime.now())) {
|
||||||
|
// 만료 컨텍스트는 정리(감사는 verify/cancel 경로에서 처리)
|
||||||
|
session.removeAttribute(ATTR_CONTEXT);
|
||||||
|
if (context.getRecipient() != null) {
|
||||||
|
authNumberStorage.deleteAuthNumber(context.getRecipient());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSameFlow(TwoFactorContext ctx, TwoFactorContext.Mode mode, String purpose) {
|
||||||
|
return ctx.getMode() == mode && Objects.equals(ctx.getPurpose(),
|
||||||
|
mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
|
||||||
|
private void discardContext(HttpSession session, TwoFactorContext ctx) {
|
||||||
|
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||||
|
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
userLogService.logFailure(loginId, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
|
||||||
|
}
|
||||||
|
if (ctx.getRecipient() != null) {
|
||||||
|
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||||
|
}
|
||||||
|
session.removeAttribute(ATTR_CONTEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검증 실패로 절차 종료: 인증번호 삭제 + 감사 + 컨텍스트/pending 정리 */
|
||||||
|
private void terminateWithFailure(HttpSession session, TwoFactorContext ctx,
|
||||||
|
LoginFailureReason reason, HttpServletRequest request) {
|
||||||
|
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||||
|
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason);
|
||||||
|
clearPending(session);
|
||||||
|
}
|
||||||
|
if (ctx.getRecipient() != null) {
|
||||||
|
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||||
|
}
|
||||||
|
session.removeAttribute(ATTR_CONTEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearPending(HttpSession session) {
|
||||||
|
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||||
|
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1차 인증~2FA 사이 계정 상태 재검증. 문제 있으면 사용자 안내 메시지 반환, 정상이면 null */
|
||||||
|
private String revalidateLoginState(PortalUser user) {
|
||||||
|
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||||
|
return "계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.";
|
||||||
|
}
|
||||||
|
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||||
|
return "법인 관리자에 의해 비활성화된 계정입니다.";
|
||||||
|
}
|
||||||
|
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||||
|
return "사용자 승인 대기중입니다.";
|
||||||
|
}
|
||||||
|
if (user.getPortalOrg() != null
|
||||||
|
&& !PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus())) {
|
||||||
|
return "로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** 2FA 발송 가능 채널 1건. masked 는 화면 표기용 마스킹 수신처. */
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TwoFactorChannel {
|
||||||
|
/** EMAIL | SMS */
|
||||||
|
private String type;
|
||||||
|
/** 마스킹된 수신처 (예: te**@ex**.com, 010-12**-34**) */
|
||||||
|
private String masked;
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** GET /auth/2fa/info 응답. 팝업 초기 렌더용. */
|
||||||
|
@Data
|
||||||
|
public class TwoFactorInfoResponse {
|
||||||
|
/** 컨텍스트 유효 여부(로그인 pending 또는 인증 사용자). false 면 팝업 진입 불가 */
|
||||||
|
private boolean available;
|
||||||
|
/** LOGIN | STEPUP */
|
||||||
|
private String mode;
|
||||||
|
/** 발송 가능 채널(휴대폰 없으면 이메일만) */
|
||||||
|
private List<TwoFactorChannel> channels;
|
||||||
|
/** 인증번호 유효시간(초) — 타이머 초기값 */
|
||||||
|
private int ttlSeconds;
|
||||||
|
/** 테스트용 인증번호 노출 여부 */
|
||||||
|
private boolean testNoticeEnabled;
|
||||||
|
/** 이미 진행 중인 절차 존재 여부(다른 탭/페이지) */
|
||||||
|
private boolean inProgress;
|
||||||
|
/** 진행 중인 절차의 안내 메시지(있으면) */
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** POST /auth/2fa/send 응답. */
|
||||||
|
@Data
|
||||||
|
public class TwoFactorSendResponse {
|
||||||
|
private boolean valid;
|
||||||
|
private String message;
|
||||||
|
/** 타이머 유효시간(초) */
|
||||||
|
private int ttlSeconds;
|
||||||
|
/** 테스트용 인증번호(테스트 노출 활성 시에만 채워짐) */
|
||||||
|
private String testAuthNumber;
|
||||||
|
/** 이미 진행 중인 절차가 있어 발송을 막은 경우 true (confirm 후 force 재요청 유도) */
|
||||||
|
private boolean inProgress;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user