32 Commits

Author SHA1 Message Date
hong 045f354e5c Q&A 리스트 퍼블리싱 2026-07-09 10:45:20 +09:00
hong 4f577c8c14 Faq , q&a 상세 , 피드백 , 마이페이지 이용자 관리 퍼블리싱 2026-07-09 09:47:16 +09:00
hong cc4c1a6b53 feat : 오픈 API & 공지사항 퍼블리싱 2026-07-06 17:21:58 +09:00
hong 551a2da717 feat: 회원가입 안내 및 oauth 2 페이지 퍼블리싱 2026-07-03 16:41:24 +09:00
hong 1c57d348e6 디자인 시안 적용 및 애니메이션 개선 2026-07-02 17:52:59 +09:00
Rinjae f79e528fec 이메일 인증 재발송 기능 제거 및 관련 코드 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `emailResendPopup` 및 팝업 호출 로직 삭제
- `InquiryAdminNotifier` → `CommunityAdminNotifier`로 클래스 명칭 변경
- 제휴 신청 시 관리자 알림 로직 추가
2026-06-24 19:45:37 +09:00
Rinjae 6f776ed72a 로그아웃 핸들러 추가: 세션 무효화 전 DB 정리 및 로깅 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-24 16:52:09 +09:00
Rinjae e242d3ed06 - 모바일 약관 탭 UI 개선 - 스크롤 추가 및 스타일 조정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 필수 입력 경고 메세지 추가 및 인증 미완료 경고 추가
2026-06-24 15:32:18 +09:00
Rinjae d7abe2c2e5 - 테스트 데이터에 휴대폰 번호 추가 및 관련 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 조건: 이메일 → 휴대폰 번호로 변경
2026-06-23 21:06:28 +09:00
Rinjae 84d92fced9 - 초대 로직 변경: 이메일→휴대폰 기반 초대 전환 및 UI 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 알림 수신 동의 추가: 초대/취소 시 알림 발송 옵션 탑재
- 관리자 기능 추가: 소속 제외 및 권한 조정 기능 구현
2026-06-23 20:58:27 +09:00
Rinjae 53468c3b83 WAR 파일 해시 파일 확장자 변경 및 MD5SUM 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-23 13:58:02 +09:00
Rinjae 198f6fc82a - 회원가입 이메일 인증 페이지 추가 및 이메일 인증 흐름 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 휴대폰 번호 중복 검증 로직 추가 및 프로퍼티 기반 설정 활성화
- 알림 수신 동의 항목 및 뷰 로직 추가
2026-06-22 19:20:03 +09:00
Rinjae 21c500610c Damo 적용 준비 코드
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- KjbNotRnnoJpaAttributeConverter 삭제 및 PersonalDataEncryptConverter 추가
- damo-manager 지원을 위한 JSP 추가 및 레거시 암호화 마이그레이션 컨트롤러 구현
- 기존 엔티티 암호화 로직 변경 및 build.gradle에 damo-manager 의존성 추가
2026-06-22 16:29:45 +09:00
Rinjae 687d4798d4 - error 페이지 개발 환경용 상세 오류 정보 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 스택 트레이스 및 예외 클래스 노출 로직 구현
- activeProfile 표시 및 스타일 업데이트
2026-06-22 15:39:59 +09:00
Rinjae 5adfd42751 - CSRF 토큰 저장소 쿠키 → 세션 전환 및 클라이언트 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- error 페이지 텍스트 동적 처리 추가 (prod 환경 구분)
- CSRF 메타 데이터 추가 및 JS/AJAX CSRF 로직 일원화
2026-06-22 14:19:32 +09:00
Rinjae 6694715195 Merge branch 'feature/session-timeout'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-06-19 18:09:47 +09:00
Rinjae fc96c13df2 - 로그인 중복 세션 확인 및 기존 세션 강제 로그아웃 처리 추가
- 세션 타이머 데스크톱·모바일 표시 및 경고 스타일 동기화
2026-06-19 18:06:40 +09:00
Rinjae 7265ad6589 - 세션 타이머·유휴 자동 로그아웃 기능 추가
- 만료 직전 연장 확인 모달, PortalProperty로 시간 설정
- PTL_USER_SESSION 기반 세션 검증 필터/API 도입
- 로그인 시 중복 세션 강제 로그아웃 처리
- 세션 테이블 DDL 및 로그인 안내 메시지 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:45:37 +09:00
Rinjae 02f9d41334 OAuth2 개발가이드 추가 및 테이블 UI 개선
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OAuth2 개발가이드 링크 추가
- 테이블 컬럼 스타일 조정: 모바일 대응 및 오버플로 방지
- Jackson 직렬화 대응 날짜 포맷 수정
2026-06-19 17:22:33 +09:00
Rinjae fe4b176512 CSS source map 경로 수정 및 매핑 데이터 규칙 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-19 10:27:17 +09:00
Rinjae 17c27e4398 문의 상태값 수정: IN_PROGRESS → RESPONDED
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 문의 상태 필드 및 관련 로직/뷰 업데이트
- Validation 패턴 및 테스트 코드 반영
- 뷰 상태 배지 및 응답 섹션 표시 로직 수정
2026-06-19 10:16:16 +09:00
Rinjae 10ff5d6f2f 마스킹 표준화 적용 (eapim-admin/portal/JS)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin: elink-online-core 표준 위임(e메일/이름/전화/IP)으로 규칙 일원화
- portal/JS: 표준 로직 직접 구현 + 기존 owner 비마스킹 유지
- 호출처 무수정/인라인 중복 제거/테스트 통과
2026-06-18 17:56:32 +09:00
Rinjae-gf63 f671f491b7 - PortalUser-테이블명 대소문자 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Credential-EqualsAndHashCode 어노테이션 추가
- 사업제휴신청 메시지 수정 및 파일 null 검사 추가
2026-06-18 16:28:22 +09:00
Rinjae-gf63 3d68589ae7 - 불필요 로그 설정 및 springProfile(gf63) 제거
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- "사업 제휴 문의" 텍스트를 "피드백/개선요청"으로 변경
- 과금 관리 관련 링크 제거
2026-06-18 16:03:03 +09:00
rinjae 7268aef211 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:51:14 +09:00
rinjae 85b2f01ce0 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 13:43:24 +09:00
rinjae 0f255d7337 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:17:31 +09:00
rinjae a8323e78f5 Jenkinsfile.deploy 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-18 12:11:00 +09:00
Rinjae fa8fce4f2d Jenkinsfile 추가 및 CI/CD 스크립트 통합
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 기존 *.sh 스크립트 제거 및 Jenkinsfile.deploy로 통합
- 테스트 전용 Jenkinsfile.test-build 추가
2026-06-18 11:35:12 +09:00
Rinjae 4e7044b9fe 폰트 패밀리 정의 분리 및 웹폰트 리소스 경로 정비
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OneShinhan, Spoqa Han Sans Neo 등 역할별 폰트 패밀리 분리
- 제목/본문 영역에 맞는 폰트 설정 및 재정의
- 웹폰트 리소스 URL 경로 및 성능 최적화
2026-06-17 20:47:01 +09:00
Rinjae bddd7e3f85 공지사항/Q&A 개선 - 장애 점검 정보 노출 및 상단고정 정렬 - Q&A 이벤트 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-16 14:54:02 +09:00
Rinjae 71f4cb6f79 Q&A 이벤트 리팩토링
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- InquiryCommentCreatedAdminEvent 삭제 및 경로 변경
- userName 정보 추가 및 description 업데이트
- inquirySubject 처리 로직 개선
2026-06-15 18:57:51 +09:00
158 changed files with 11786 additions and 5113 deletions
+5
View File
@@ -108,3 +108,8 @@ diff
*rinjae*
*gf63*
# Local publishing and config files
/publishing/
/src/main/resources/application-local.yml
/src/main/resources/logback-local.xml
+220
View File
@@ -0,0 +1,220 @@
pipeline {
agent { label 'djb-vm' }
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
JAVA_HOME = '/apps/opts/jdk8'
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
NODE_HOME = '/apps/opts/node-v24'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
CATALINA_BASE = '/prod/eapim/devportal'
CATALINA_HOME = '/prod/eapim/apache-tomcat-9.0.116'
CATALINA_PID = '/prod/eapim/devportal/temp/catalina.pid'
DEPLOY_HTTP_PORT = '39130'
WEBHOOK_URL = 'http://172.30.1.50:18000/api/hooks/01ba51b01d3c4c98ae8f3183a23a84ed713197857d024b5da4f303458195eb32'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
rm -rf elink-portal-common
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
rm -rf eapim-online/elink-online-core-jpa
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
java -version
gradle --version
'''
}
}
stage('Test') {
steps {
sh 'gradle clean test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
}
}
}
stage('Build WAR') {
steps {
sh 'gradle build -x test --no-daemon'
}
post {
success {
sh '''
set -eu
cd build/libs
for f in eapim-portal.war eapim-portal-boot.war; do
sha1sum "$f" > "$f.sha1"
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
}
}
}
stage('Stop Tomcat') {
steps {
sh '''
set +e
systemctl --user stop eapim-portal 2>/dev/null
if [ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null; then
JAVA_HOME="$JAVA_HOME_TOMCAT" "$CATALINA_HOME/bin/shutdown.sh" 30 -force || true
for i in $(seq 1 30); do
[ -f "$CATALINA_PID" ] && kill -0 "$(cat "$CATALINA_PID")" 2>/dev/null || break
sleep 1
done
fi
rm -f "$CATALINA_PID"
'''
}
}
stage('Deploy ROOT.war') {
steps {
sh '''
set -eu
DEPLOY_DIR="$CATALINA_BASE/webapps"
rm -rf "$DEPLOY_DIR/ROOT" "$DEPLOY_DIR/ROOT.war"
cp build/libs/eapim-portal.war "$DEPLOY_DIR/ROOT.war"
ls -la "$DEPLOY_DIR"
'''
}
}
stage('Start Tomcat and readiness') {
steps {
sh '''
set -eu
LOG="$CATALINA_BASE/logs/catalina.$(date +%Y-%m-%d).log"
BEFORE=0
[ -f "$LOG" ] && BEFORE=$(stat -c %s "$LOG")
systemctl --user start eapim-portal
DEADLINE=$(($(date +%s) + 120))
LIVE_OK=0
STATUS=000
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
if [ "$LIVE_OK" = "0" ]; then
LIVE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 \
"http://localhost:$DEPLOY_HTTP_PORT/health" 2>/dev/null || echo "000")
if [ "$LIVE" = "200" ]; then
echo "Liveness OK (/health)"
LIVE_OK=1
fi
fi
if [ "$LIVE_OK" = "1" ]; then
STATUS=$(curl -sS -o /tmp/eapim-ready-body.json -w '%{http_code}' --max-time 3 \
"http://localhost:$DEPLOY_HTTP_PORT/health/ready" 2>/dev/null || echo "000")
if [ "$STATUS" = "200" ]; then
echo "Readiness OK (/health/ready)"
cat /tmp/eapim-ready-body.json
echo
break
fi
fi
if [ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | grep -qE "Application listener .* Exception|SEVERE.*startup.Catalina"; then
echo "Startup error detected in log"
tail -c +$((BEFORE+1)) "$LOG" | tail -80
exit 1
fi
sleep 2
done
if [ "$STATUS" != "200" ]; then
echo "Readiness probe failed within 120s, last HTTP status: $STATUS"
[ -f /tmp/eapim-ready-body.json ] && cat /tmp/eapim-ready-body.json && echo
[ -f "$LOG" ] && tail -c +$((BEFORE+1)) "$LOG" | tail -100
exit 1
fi
echo "--- key boot log lines ---"
tail -c +$((BEFORE+1)) "$LOG" | grep -E "Server startup|Deployment of web" | head -10 || true
'''
}
}
}
post {
success {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) SUCCESS","attachments":[{"color":"good","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"SUCCESS","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for SUCCESS"
'''
}
failure {
sh '''
set +e
payload=$(printf '{"text":"[%s #%s](%s) FAILURE","attachments":[{"color":"danger","fields":[{"title":"Job","value":"%s","short":true},{"title":"Build","value":"%s","short":true},{"title":"Result","value":"FAILURE","short":true}]}]}' \
"$JOB_NAME" "$BUILD_NUMBER" "$BUILD_URL" "$JOB_NAME" "$BUILD_NUMBER")
curl -sS --fail --max-time 5 \
-H 'Content-Type: application/json' \
-X POST \
--data "$payload" \
"$WEBHOOK_URL" >/dev/null || echo "Webhook delivery failed for FAILURE"
'''
}
}
}
+105
View File
@@ -0,0 +1,105 @@
pipeline {
agent { label 'djb-vm' }
triggers {
pollSCM('H/10 * * * *')
}
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
JAVA_HOME = '/apps/opts/jdk8'
JAVA_HOME_TOMCAT = '/apps/opts/jdk17'
GRADLE_HOME = '/apps/opts/gradle-8.7'
GRADLE_USER_HOME = '/apps/opts/gradle-home'
NODE_HOME = '/apps/opts/node-v24'
PATH = "/apps/opts/jdk8/bin:/apps/opts/gradle-8.7/bin:/apps/opts/node-v24/bin:/apps/opts/bin:${env.PATH}"
GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=accept-new'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Checkout dependencies') {
steps {
sh '''
set -eu
cd "$WORKSPACE/.."
if [ ! -d elink-portal-common/.git ]; then
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-portal-common.git \
elink-portal-common
else
git -C elink-portal-common fetch --depth=1 origin master
git -C elink-portal-common reset --hard origin/master
git -C elink-portal-common clean -fdx
fi
mkdir -p eapim-online
if [ ! -d eapim-online/elink-online-core-jpa/.git ]; then
git clone --depth=1 --branch master \
ssh://git@172.30.1.50:2222/djb-eapim/elink-online-core-jpa.git \
eapim-online/elink-online-core-jpa
else
git -C eapim-online/elink-online-core-jpa fetch --depth=1 origin master
git -C eapim-online/elink-online-core-jpa reset --hard origin/master
git -C eapim-online/elink-online-core-jpa clean -fdx
fi
'''
}
}
stage('Verify toolchain') {
steps {
sh '''
set -eu
java -version
gradle --version
node --version || true
npm --version || true
'''
}
}
stage('Test') {
steps {
sh 'gradle clean test --no-daemon'
}
post {
always {
junit allowEmptyResults: true, testResults: 'build/test-results/test/*.xml'
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/tests/test/**,build/test-results/test/**'
}
}
}
stage('Build WAR') {
steps {
sh 'gradle build -x test --no-daemon'
}
post {
success {
sh '''
set -eu
cd build/libs
for f in eapim-portal.war eapim-portal-boot.war; do
sha1sum "$f" > "$f.sha1"
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
}
}
}
}
}
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
echo "========================"
java -version
javac -version
echo "========================"
gradle --version
echo "========================"
gradle clean build -x test --no-daemon
+6 -5
View File
@@ -150,11 +150,11 @@ compileJava {
}
processResources {
exclude { details ->
details.file.name.startsWith('application-') &&
details.file.name.endsWith('.yml') &&
!(details.file.name in ['application-stage.yml', 'application-prod.yml'])
}
// exclude { details ->
// details.file.name.startsWith('application-') &&
// details.file.name.endsWith('.yml') &&
// !(details.file.name in ['application-stage.yml', 'application-prod.yml'])
// }
}
test {
@@ -170,6 +170,7 @@ test {
bootWar {
archiveFileName = "eapim-portal-boot.war"
mainClass = 'com.eactive.apim.portal.PortalApplication'
}
war {
-3
View File
@@ -1,3 +0,0 @@
./gradlew bootWar
docker build -t eactive-portal .
-31
View File
@@ -1,31 +0,0 @@
#!/bin/bash
# Check if the war file is provided
if [ -z "$1" ]; then
echo "Usage: $0 /path/to/DEVPortal.war"
exit 1
fi
# Variables
WAR_FILE="$1"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEST_DIR="/app/apim/deploy/DVPWeb"
BACKUP_DIR="/app/apim/backup"
# Create backup directory if not exists
mkdir -p $BACKUP_DIR
# Backup the existing directory
if [ -d "$DEST_DIR" ]; then
echo "Backing up existing directory..."
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
fi
# Create destination directory if not exists
mkdir -p $DEST_DIR
# Unzip the file to the dedicated location
echo "Extracting WAR file to $DEST_DIR..."
unzip $WAR_FILE -d $DEST_DIR
echo "deploy successful"
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Check if the war file is provided
if [ -z "$1" ]; then
echo "Usage: $0 /path/to/DEVPortal.war"
exit 1
fi
# Variables
WAR_FILE="$1"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
DEST_DIR="/app/apim/deploy/DVPWeb"
STATIC_DIR="/app/apim/deploy/static"
BACKUP_DIR="/app/apim/backup"
# Create backup directory if not exists
mkdir -p $BACKUP_DIR
# Backup the existing directory
if [ -d "$STATIC_DIR" ]; then
echo "Backing up existing static directory..."
mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
fi
if [ -d "$DEST_DIR" ]; then
echo "Backing up existing directory..."
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
fi
# Create destination directory if not exists
mkdir -p $DEST_DIR
mkdir -p $STATIC_DIR
# unzip the file to the dedicated location
echo "Extracting WAR file to $DEST_DIR..."
unzip $WAR_FILE -d $DEST_DIR
mv $DEST_DIR/static/* $STATIC_DIR/
echo "deploy successful"
-11
View File
@@ -1,11 +0,0 @@
#!/bin/bash
export JAVA_HOME=/c/eactive/apps/jdk1.8.0_441
export GRADLE_HOME=/c/eactive/apps/gradle-8.7
export GRADLE_USER_HOME=/c/eactive/workspaces/gradle-user-home-kjbank
export PATH=$JAVA_HOME/bin:$GRADLE_HOME/bin:$PATH
# gradle 명령을 대체합니다.
command gradle $@
Vendored Regular → Executable
View File
+2 -1
View File
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
)
;
create table DVPOWN.PT_TOKEN
create table PT_TOKEN
(
(
TOKEN VARCHAR2(255) not null
primary key,
@@ -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;
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
public PortalApplication() {
super();
}
public static void main(String[] args) {
portal_logger.info("##### PortalApplication Start #####");
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping("/agreements")
public class AgreementsController {
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
public static final String TERMS_AGREEMENTS = "fragment/djbank/terms_agreements";
/** 개인정보처리방침은 약관 페이지에서 분리되어 제주은행 공식 사이트 외부 링크로 대체됨 */
public static final String PRIVACY_POLICY_EXTERNAL_URL =
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
private final AgreementsFacade agreementsFacade;
@@ -28,28 +32,49 @@ public class AgreementsController {
public String showTerms(@RequestParam(required = false) String tab,
@RequestParam(required = false) String publishedOn,
Model model) {
String currentTab = tab != null ? tab : "terms";
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
if ("privacy".equals(currentTab)) {
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
}
AgreementType type;
if ("privacy".equals(tab)) {
type = AgreementType.PRIVACY_POLICY;
} else {
type = AgreementType.TERMS_OF_USE;
switch (currentTab) {
case "privacy-collect":
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
type = AgreementType.PRIVACY_COLLECT;
break;
case "notification":
type = AgreementType.NOTIFICATION_CONSENT;
break;
case "terms":
default:
currentTab = "terms";
type = AgreementType.TERMS_OF_USE;
break;
}
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
model.addAttribute("agreementsList", agreementsList);
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
AgreementsDTO selectedAgreement = null;
if (agreementsList != null && !agreementsList.isEmpty()) {
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
}
model.addAttribute("selectedAgreement", selectedAgreement);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
model.addAttribute("agreementTitle", type.getDescription());
model.addAttribute("agreementType", type.getCode());
model.addAttribute("currentTab", tab != null ? tab : "terms");
model.addAttribute("currentTab", currentTab);
return TERMS_AGREEMENTS;
}
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
}
// 2. 개인정보수집동의서 처리
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
if (privacyCollectType != null
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
Optional<Agreements> privacyCollect = agreementsService
.findLastesBeforeDate(
privacyCollectType,
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
defaultAgreement.setName("개인정보처리방침");
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT":
defaultAgreement.setName("개인정보수집동의서");
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT_IND":
defaultAgreement.setName("개인용 개인정보수집동의서");
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.LocalDateTime;
import java.util.Optional;
@Component
public class AuthNumberStorage {
private static final String HASH_ALGORITHM = "HmacSHA256";
private final TwoFactorAuthRepository twoFactorAuthRepository;
/**
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
*/
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String hashPepper;
@Autowired
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
this.twoFactorAuthRepository = twoFactorAuthRepository;
}
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
String lookupKey = hashRecipient(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
auth.setRecipientKey(lookupKey);
twoFactorAuthRepository.save(auth);
}
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
return twoFactorAuthRepository.findByRecipient(recipientKey);
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
}
public void deleteAuthNumber(String recipientKey) {
twoFactorAuthRepository.deleteById(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
}
/**
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
*/
private String hashRecipient(String recipient) {
if (recipient == null) {
return null;
}
try {
Mac mac = Mac.getInstance(HASH_ALGORITHM);
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
}
}
}
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.apps.community.notice.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class IncidentAffectedApiDTO {
private String apiId;
private String apiName;
}
@@ -8,11 +8,18 @@ import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PortalNoticeDTO {
public static final String NOTICE_TYPE_NORMAL = "1";
public static final String NOTICE_TYPE_MAINTENANCE = "2";
public static final String NOTICE_TYPE_INCIDENT = "3";
private String id;
@NotEmpty(message = "제목을 입력해주세요.")
@@ -29,9 +36,33 @@ public class PortalNoticeDTO {
private boolean useYn;
private String fixYn;
private String noticeType;
private String inquirerName;
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
// ───── 장애/점검 연동 필드 ─────
private String summary;
private LocalDateTime startedAt;
private LocalDateTime endAt;
private String state;
private String previousState;
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
public boolean isIncidentType() {
return NOTICE_TYPE_INCIDENT.equals(noticeType);
}
public boolean isMaintenanceType() {
return NOTICE_TYPE_MAINTENANCE.equals(noticeType);
}
public boolean isIncidentOrMaintenance() {
return isIncidentType() || isMaintenanceType();
}
}
@@ -1,8 +1,12 @@
package com.eactive.apim.portal.apps.community.notice.service;
import com.eactive.apim.portal.apps.community.notice.dto.IncidentAffectedApiDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
@@ -12,7 +16,10 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service("portalNoticeFacade")
@RequiredArgsConstructor
@@ -20,12 +27,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
private final PortalNoticeService portalNoticeService;
private final PortalNoticeMapper portalNoticeMapper;
private final DjbApistatusIncidentRepository incidentRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
@Override
public List<PortalNoticeDTO> getLatestNotices() {
PortalNoticeSearch search = new PortalNoticeSearch();
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
// Pageable pageable = Pageable.ofSize(3);
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
return notices.getContent();
}
@@ -36,13 +44,47 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, pageable);
// 상단고정(fixYn='Y') 우선 정렬 — 호출자의 sort 앞에 결합 (admin Service 와 일관)
Sort fixYnFirst = Sort.by(Sort.Direction.DESC, "fixYn");
Pageable fixedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
fixYnFirst.and(pageable.getSort())
);
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, fixedPageable);
return noticesPage.map(portalNoticeMapper::map);
}
@Override
public PortalNoticeDTO getNotice(String id) {
PortalNotice portalNotice = portalNoticeService.getNotice(id);
return portalNoticeMapper.map(portalNotice);
PortalNoticeDTO dto = portalNoticeMapper.map(portalNotice);
populateIncident(dto);
return dto;
}
private void populateIncident(PortalNoticeDTO dto) {
if (!dto.isIncidentOrMaintenance()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
if (!incidentOpt.isPresent()) {
dto.setAffectedApis(Collections.emptyList());
return;
}
DjbApistatusIncident incident = incidentOpt.get();
dto.setSummary(incident.getSummary());
dto.setStartedAt(incident.getStartedAt());
dto.setEndAt(incident.getEndAt());
dto.setState(incident.getState() == null ? null : incident.getState().name());
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
List<IncidentAffectedApiDTO> apis = incidentApiRepository
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
.collect(Collectors.toList());
dto.setAffectedApis(apis);
}
}
@@ -54,7 +54,7 @@ public class PartnershipApplicationController {
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
// 성공 메시지 추가
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
httpServletRequest.getSession().removeAttribute("previousPage");
return "redirect:/partnership";
@@ -5,14 +5,18 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -21,13 +25,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationService partnershipApplicationService;
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
private final CommunityAdminNotifier portalAdminNotifier;
@Override
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
FileInfo file = null;
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
try {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
@@ -43,5 +49,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
partnershipApplication.setFileId(file.getFileId());
}
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
// 개선요청/제휴 게시물 등록 시 portal-admin 에게 알림 (INQUIRY_CREATED 와 동일 패턴)
PortalAuthenticatedUser writer = SecurityUtil.getPortalAuthenticatedUser();
Map<String, Object> params = new HashMap<>();
params.put("partnershipId", partnershipApplication.getId());
params.put("bizSubject", partnershipApplication.getBizSubject());
if (writer != null) {
params.put("writerName", writer.getUserName());
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
}
@@ -25,7 +25,7 @@ public class InquiryDTO {
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
private String inquiryDetail;
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
private String inquiryStatus;
private String inquirerName;
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryAdminNotifier;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.template.entity.MessageCode;
@@ -26,7 +26,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final InquiryAdminNotifier inquiryAdminNotifier;
private final CommunityAdminNotifier inquiryAdminNotifier;
@Override
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
@@ -152,25 +152,6 @@ public class AccountRecoveryController {
}
}
@PostMapping("/resend_verification_email")
public ResponseEntity<Map<String, String>> resendVerificationEmail(@RequestParam String loginId) {
Map<String, String> response = new HashMap<>();
try {
portalUserAuthService.resendVerificationEmail(loginId);
response.put("success", "인증 이메일이 재발송되었습니다.");
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
} catch (Exception e) {
response.put("error", "이메일 발송 중 오류가 발생했습니다.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@GetMapping("/dormant_account")
public String showDormantAccountPage(HttpSession session,Model model) {
@@ -0,0 +1,130 @@
package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/session")
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("valid", false);
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
if (forceLoggedOut) {
result.put("valid", false);
result.put("remainingSeconds", 0);
} else {
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("valid", remaining > 0);
result.put("remainingSeconds", remaining);
}
return ResponseEntity.ok(result);
}
/**
* 하트비트 - lastAccessTime 갱신 (세션 연장)
*/
@PostMapping("/heartbeat")
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
userSessionService.updateLastAccessTime(sessionId);
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("remainingSeconds", remaining);
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.session.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
*
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "PTL_USER_SESSION")
public class UserSession {
@Id
@Column(name = "SESSION_ID", length = 128, nullable = false)
private String sessionId;
@Column(name = "USER_ID", length = 36, nullable = false)
private String userId;
@Column(name = "LOGIN_ID", length = 200, nullable = false)
private String loginId;
@Column(name = "LOGIN_TIME", nullable = false)
private LocalDateTime loginTime;
@Column(name = "LAST_ACCESS_TIME", nullable = false)
private LocalDateTime lastAccessTime;
@Column(name = "IP_ADDRESS", length = 45)
private String ipAddress;
@Column(name = "USER_AGENT", length = 500)
private String userAgent;
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
@Builder.Default
private String forceLogout = "N";
}
@@ -0,0 +1,124 @@
package com.eactive.apim.portal.apps.session.filter;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
*
* <ol>
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionValidationFilter extends OncePerRequestFilter {
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
"/api/session/",
"/login",
"/actionLogin.do",
"/actionLogout.do",
"/css/",
"/js/",
"/img/",
"/plugins/",
"/fonts/",
"/favicon.ico",
"/error",
"/health"
);
private final UserSessionService userSessionService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty()) {
path = path.substring(contextPath.length());
}
// 제외 경로 체크
if (isExcludedPath(path)) {
filterChain.doFilter(request, response);
return;
}
// 인증되지 않은 요청은 통과
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
filterChain.doFilter(request, response);
return;
}
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
filterChain.doFilter(request, response);
return;
}
String sessionId = httpSession.getId();
// 1. 강제 로그아웃 체크 (중복 로그인)
if (userSessionService.isForceLoggedOut(sessionId)) {
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?forceLogout=true");
return;
}
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
long remaining = userSessionService.getRemainingSeconds(sessionId);
if (remaining <= 0) {
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?expired=true");
return;
}
// 3. lastAccessTime 갱신 (60초 스로틀링)
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
long now = System.currentTimeMillis();
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
userSessionService.updateLastAccessTime(sessionId);
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
}
filterChain.doFilter(request, response);
}
private boolean isExcludedPath(String path) {
for (String prefix : EXCLUDED_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,29 @@
package com.eactive.apim.portal.apps.session.repository;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
List<UserSession> findByLoginId(String loginId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@Modifying
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
}
@@ -0,0 +1,161 @@
package com.eactive.apim.portal.apps.session.service;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserSessionService {
private static final String PROPERTY_GROUP = "Portal";
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
private static final long CLEANUP_MARGIN_MINUTES = 1;
private final UserSessionRepository userSessionRepository;
private final PortalPropertyService portalPropertyService;
/**
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
*/
@Transactional(readOnly = true)
public Optional<UserSession> getActiveSession(String loginId) {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
return sessions.stream()
.filter(s -> "N".equals(s.getForceLogout()))
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
.findFirst();
}
/**
* 새 세션 등록
*/
@Transactional
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
UserSession session = UserSession.builder()
.sessionId(sessionId)
.userId(userId)
.loginId(loginId)
.loginTime(LocalDateTime.now())
.lastAccessTime(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(truncate(userAgent, 500))
.forceLogout("N")
.build();
userSessionRepository.save(session);
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
}
/**
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
*/
@Transactional
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
if (count > 0) {
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@Transactional(readOnly = true)
public boolean isForceLoggedOut(String sessionId) {
return userSessionRepository.findById(sessionId)
.map(s -> "Y".equals(s.getForceLogout()))
.orElse(false);
}
/**
* 세션 삭제
*/
@Transactional
public void removeSession(String sessionId) {
if (userSessionRepository.existsById(sessionId)) {
userSessionRepository.deleteById(sessionId);
log.debug("세션 삭제 - sessionId: {}", sessionId);
}
}
/**
* 마지막 접근 시간 갱신 (세션 연장)
*/
@Transactional
public void updateLastAccessTime(String sessionId) {
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
*/
public int getSessionTimeoutMinutes() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
PROPERTY_NAME,
DEFAULT_TIMEOUT_MINUTES,
"세션 타임아웃 시간 (분)"
);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
}
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@Transactional(readOnly = true)
public long getRemainingSeconds(String sessionId) {
int timeoutMinutes = getSessionTimeoutMinutes();
return userSessionRepository.findById(sessionId)
.map(s -> {
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
return Math.max(0, remaining);
})
.orElse(0L);
}
/**
* 만료 세션 정리 (1분 주기).
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
*/
@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpiredSessions() {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
if (deleted > 0) {
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
}
}
private String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}
@@ -142,8 +142,8 @@ public class AccountController {
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getEmailAddr(), InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
currentUser.getMobileNumber(), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
@@ -262,7 +262,7 @@ public class AccountController {
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("registrationType", "corporate");
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
return "apps/mypage/orgTransfer";
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import org.springframework.web.bind.annotation.PostMapping;
@@ -22,18 +23,22 @@ public class AuthController {
private final AuthFacade authFacade;
private final CellPhoneValidator cellPhoneValidator;
private final EmailValidator emailValidator;
private final PortalUserService portalUserService;
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
PortalUserService portalUserService) {
this.authFacade = authFacade;
this.cellPhoneValidator = cellPhoneValidator;
this.emailValidator = emailValidator;
this.portalUserService = portalUserService;
}
@PostMapping("/request_auth_number")
public ValidationResponse requestAuthNumber(
@RequestParam(required = false) String mobileNumber,
@RequestParam(required = false) String purpose,
HttpSession session) {
ValidationResponse response = new ValidationResponse();
@@ -44,6 +49,16 @@ public class AuthController {
return response;
}
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(mobileNumber)) {
response.setValid(false);
response.setMessage("이미 가입된 휴대폰 번호입니다.");
return response;
}
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
// 이전 세션 데이터 정리
@@ -29,7 +29,8 @@ public class OrgRegisterController {
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_ORG_REGISTER;
}
@@ -74,6 +75,7 @@ public class OrgRegisterController {
if (response.isValid()) {
model.addAttribute("message", response.getMessage());
model.addAttribute("registrationType", "corporate");
return MAIN_REGISTER_RESULT;
} else {
setModelForErrorOrg(model, orgDTO, response.getMessage());
@@ -91,6 +93,7 @@ public class OrgRegisterController {
model.addAttribute("portalOrg", orgDTO);
model.addAttribute("error", errorMessage);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
}
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
import com.eactive.apim.portal.common.dto.ResponseDTO;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.PostMapping;
@@ -18,12 +19,16 @@ import org.springframework.web.bind.annotation.RestController;
public class UserManRestController {
private final UserManFacade userManFacade;
private final CellPhoneValidator cellPhoneValidator;
// 1. Cancel invitation
@PostMapping("/cancel-invitation")
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
boolean notifyConsent = user.isNotifyConsent();
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
// 2. Inactivate user
@@ -40,25 +45,25 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
}
//5. register new user
//5. register new user (휴대폰 번호 기반 초대)
@PostMapping("/invite")
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
String email = newUser.getEmailAddr();
String mobile = newUser.getMobileNumber();
// 이메일 형식 검증
if (email == null || email.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
// 휴대폰 번호 검증
if (mobile == null || mobile.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
}
if (!cellPhoneValidator.isValid(mobile, null)) {
return new ResponseDTO(400, "ERROR", "올바른 휴대폰 번호 형식을 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!email.matches(emailPattern)) {
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
}
boolean notifyConsent = newUser.isNotifyConsent();
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대 메시지가 발송되었습니다."
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
@PostMapping("/change-role")
@@ -69,6 +74,20 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
}
// 관리자 -> 이용자 변경 (본인 제외)
@PostMapping("/revoke-manager")
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
}
// 소속 제외 -> 개인이용자로 전환
@PostMapping("/remove-from-org")
public ResponseDTO removeFromOrg(@RequestBody PortalUserDTO user) {
userManFacade.removeFromOrg(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "소속에서 제외되어 개인이용자로 변경되었습니다.");
}
// 6. Resend invitation
@PostMapping("/resend-invitation")
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
@@ -16,11 +17,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
@@ -50,6 +55,7 @@ public class UserRegisterController {
private final PortalUserService portalUserService;
private final UserRegisterFacade userRegisterFacade;
private final AuthFacade authFacade;
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final AgreementsFacade agreementsFacade;
@@ -67,8 +73,6 @@ public class UserRegisterController {
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
boolean isInvited = false;
String orgName = null;
String email = null;
String[] parts = null;
if (invitationToken != null) {
// 8글자 토큰 사용
@@ -80,29 +84,23 @@ public class UserRegisterController {
String orgId = invitation.get().getOrgId();
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
orgName = org.map(PortalOrg::getOrgName).orElse(null);
email = invitation.get().getInvitationEmail();
parts = email.split("@");
}
}
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
model.addAttribute("isInvited", isInvited);
model.addAttribute("orgName", orgName);
model.addAttribute("email", email);
model.addAttribute("loginId", email);
if (parts != null) {
model.addAttribute("emailId", parts[0]);
model.addAttribute("domain", parts[1]);
} else {
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
}
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 는 가입자가 직접 입력한다
model.addAttribute("email", "");
model.addAttribute("loginId", "");
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_USER_REGISTER;
@@ -138,8 +136,21 @@ public class UserRegisterController {
}
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
session.removeAttribute("invitationToken");
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
if (invitationToken == null) {
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
if (registered.isPresent()
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
return "redirect:/signup/verification-email";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return "redirect:/signup/complete";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
return "redirect:/signup/complete/corporate";
} catch (Exception e) {
setModelForError(session, model, agreement, portalUserRegistrationDTO,
"처리 중 오류가 발생했습니다: " + e.getMessage());
@@ -169,6 +180,57 @@ public class UserRegisterController {
return MAIN_EMAIL_COMPLETED;
}
/**
* 회원가입 직후 이메일 인증 페이지.
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
*/
@GetMapping("/signup/verification-email")
public String showSignupVerificationEmail(HttpSession session, Model model) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return "redirect:/login";
}
model.addAttribute("email", email);
return "apps/register/signupVerificationEmail";
}
/**
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
*/
@PostMapping("/signup/send-verification-code")
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
return ResponseEntity.ok(response);
}
/**
* 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다.
*/
@PostMapping("/signup/verify-email-code")
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
String code = request.get("code");
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
if (response.isValid()) {
PortalUser user = portalUserService.findByEmailAddr(email);
portalUserService.activateUser(user.getId());
session.removeAttribute("signupVerificationEmail");
}
return ResponseEntity.ok(response);
}
@GetMapping("/signup/decision")
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
HttpSession session,
@@ -188,9 +250,9 @@ public class UserRegisterController {
return "redirect:/";
}
// 이메일로 사용자 정보 조회
String email = invitation.get().getInvitationEmail();
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
// 휴대폰 번호로 사용자 정보 조회
String mobile = invitation.get().getInvitationMobile();
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
if (!portalUser.isPresent()) {
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
@@ -219,13 +281,14 @@ public class UserRegisterController {
public String showDecisionProcessPage(HttpSession session, Model model) {
session.removeAttribute("decisionToken");
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
String mobile = SecurityUtil.getPortalAuthenticatedUser().getMobileNumber();
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
if (!invitation.isPresent()) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
@@ -241,7 +304,7 @@ public class UserRegisterController {
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
@@ -260,7 +323,7 @@ public class UserRegisterController {
agreementValidator.validate(agreement, bindingResult);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber(), UserInvitationEnums.InvitationStatus.PENDING);
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
@@ -270,7 +333,7 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
@@ -294,24 +357,6 @@ public class UserRegisterController {
return "redirect:/";
}
@GetMapping("/validate_email")
public String validateUserEmail(@RequestParam(name = "token", required = false) String token) {
String decodedToken = new String(Base64.decode(token));
try {
String decToken = encryptionUtil.decrypt(decodedToken);
String[] tokens = decToken.split(":");
boolean approvalRequired = portalUserService.activateUser(tokens[1]);
if (approvalRequired) {
return "apps/register/emailValidationResultCorpManager";
} else {
return "apps/register/emailValidationResultUser";
}
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException |
BadPaddingException e) {
throw new IllegalArgumentException("인증 정보 오류. 관리자 문의하세요.");
}
}
// 에러 발생 시 모델 세팅을 위한 private 메서드
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
@@ -325,7 +370,8 @@ public class UserRegisterController {
model.addAttribute("agreement", agreement);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("msgType", "sms");
}
@@ -437,10 +483,10 @@ public class UserRegisterController {
session.removeAttribute("invitationCodeAttempts");
session.removeAttribute("invitationCodeLockoutUntil");
// 기존 사용자 여부 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
if (existingUser.isPresent()) {
if (existingUser) {
// 기존 사용자: 초대 수락 페이지로 이동
return "redirect:/signup/decision?invitation=" + normalizedCode;
} else {
@@ -23,6 +23,9 @@ public class PortalUserDTO implements Serializable {
private String emailAddr;
// 초대 시 알림 수신 동의 여부 (false 면 초대 레코드만 생성, 메시지 미발송)
private boolean notifyConsent;
private PortalOrgDTO portalOrg;
private UserStatus userStatus;
@@ -192,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
// 사용자 생성 및 기관 연결
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
@@ -240,7 +240,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
portalUserRepository.save(existingUser);
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(existingUser);
@@ -274,7 +274,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
portalUserRepository.save(existingUser);
// 새로운 법인용 개인정보수집동의서 저장
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(existingUser);
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.DurationParser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.event.UserInvitationCancelEvent;
@@ -52,6 +53,7 @@ public class UserManFacade {
private final MessageHandlerService messageHandlerService;
private final UserLogRepository userLogRepository;
private final PortalPropertyService portalPropertyService;
private final CellPhoneValidator cellPhoneValidator;
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
return portalUserRepository
@@ -94,23 +96,35 @@ public class UserManFacade {
dto.setMobileNumber(null);
}
public void sendInvitation(PortalUser sender, String emailAddr) {
// 이메일 형식 검증
if (emailAddr == null || emailAddr.trim().isEmpty()) {
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
/**
* 법인 개발자 초대 (휴대폰 번호 기반).
*
* @param sender 초대를 보내는 법인 관리자
* @param mobileRaw 초대 대상 휴대폰 번호 (하이픈 유무 무관)
* @param notifyConsent 알림 수신 동의 여부. false 면 초대 레코드만 생성하고 메시지 발송은 스킵한다.
*/
public void sendInvitation(PortalUser sender, String mobileRaw, boolean notifyConsent) {
// 휴대폰 번호 형식 검증
if (mobileRaw == null || mobileRaw.trim().isEmpty()) {
throw new IllegalArgumentException("휴대폰 번호를 입력해주세요.");
}
if (!cellPhoneValidator.isValid(mobileRaw, null)) {
throw new IllegalArgumentException("올바른 휴대폰 번호 형식을 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!emailAddr.matches(emailPattern)) {
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
}
// 저장 포맷(하이픈 포함, 예: 010-1234-5678)으로 정규화 — PortalUser.mobileNumber 저장 포맷과
// 일치시켜야 결정적 암호화 equality 조회가 매칭된다.
String mobile = normalizeMobile(mobileRaw);
// 중복 초대 확인
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
checkDuplicateInvitation(sender.getPortalOrg().getId(), mobile);
//기존 사용자인지 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
// 기존 사용자인지 휴대폰 번호로 확인 (mobileNumber 는 유니크가 아니므로 다건 가능)
List<PortalUser> matched = portalUserRepository.findAllByMobileNumber(mobile);
if (matched.size() > 1) {
throw new IllegalArgumentException("해당 휴대폰 번호로 가입된 계정이 여러 건 존재하여 초대할 수 없습니다. 관리자에게 문의해주세요.");
}
Optional<PortalUser> existingUser = matched.isEmpty() ? Optional.empty() : Optional.of(matched.get(0));
if (existingUser.isPresent()) {
PortalUser user = existingUser.get();
@@ -119,7 +133,12 @@ public class UserManFacade {
}
}
String token = createInvitation(sender, emailAddr);
String token = createInvitation(sender, mobile);
// 알림 수신 미동의 시 초대 레코드만 생성하고 메시지 발송은 스킵
if (!notifyConsent) {
return;
}
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
MessageRecipient recipient;
@@ -129,7 +148,7 @@ public class UserManFacade {
params.put("corpName", sender.getPortalOrg().getOrgName());
params.put("authNumber", token); // 8-character invitation code
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 휴대폰 번호 사용
if (existingUser.isPresent()) {
PortalUser user = existingUser.get();
params.put("loginId", user.getUserName());
@@ -137,26 +156,45 @@ public class UserManFacade {
params.put("url", "signup/decision?invitation=" + token);
recipient = MessageRecipient.of(user);
// MessageRecipient.of() 는 유선전화(phoneNumber)를 채우므로 휴대폰 번호로 보정
recipient.setPhone(user.getMobileNumber());
} else {
params.put("loginId", emailAddr);
params.put("NAME", emailAddr);
params.put("loginId", mobile);
params.put("NAME", mobile);
params.put("url", "signup/portalUser?invitation=" + token);
String emailUsername = emailAddr.contains("@") ? emailAddr.substring(0, emailAddr.indexOf("@")) : emailAddr;
recipient = MessageRecipient.builder()
.userId(emailAddr)
.username(emailUsername)
.userId(mobile)
.username(mobile)
.phone(mobile)
.build();
}
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
}
public String createInvitation(PortalUser sender, String emailAddr) {
/**
* 휴대폰 번호를 저장 포맷(하이픈 포함)으로 정규화한다.
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
*/
private String normalizeMobile(String raw) {
if (raw == null) {
return null;
}
String digits = raw.replaceAll("[^0-9]", "");
if (digits.length() == 11) {
return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
} else if (digits.length() == 10) {
return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
}
return raw;
}
public String createInvitation(PortalUser sender, String mobile) {
UserInvitation newInvitation = new UserInvitation();
newInvitation.setAdminId(sender.getId());
newInvitation.setOrgId(sender.getPortalOrg().getId());
newInvitation.setInvitationEmail(emailAddr);
newInvitation.setInvitationMobile(mobile);
// Set additional required fields
newInvitation.setInvitationDate(LocalDateTime.now());
@@ -225,7 +263,51 @@ public class UserManFacade {
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId) {
/**
* 법인 관리자 권한 회수 (관리자 → 이용자).
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
*/
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
if (targetUser.getPortalOrg() == null
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
throw new IllegalArgumentException("잘 못 된 접근입니다");
}
if (targetUser.getId().equals(admin.getId())) {
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
}
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
}
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
portalUserRepository.save(targetUser);
}
/**
* 소속 제외 (기관에서 제외 → 개인이용자).
* 대상은 본인이 아닌 같은 기관의 사용자여야 한다. portalOrg 를 비우고 ROLE_USER 로 전환한다.
*/
public void removeFromOrg(PortalAuthenticatedUser admin, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
if (targetUser.getPortalOrg() == null
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
throw new IllegalArgumentException("잘 못 된 접근입니다");
}
if (targetUser.getId().equals(admin.getId())) {
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
}
targetUser.setPortalOrg(null);
targetUser.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(targetUser);
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
UserInvitation invitation = userInvitationRepository.findById(invitationId)
.orElseThrow(() -> new NotFoundException("Invitation not found with id: " + invitationId));
@@ -237,17 +319,19 @@ public class UserManFacade {
throw new IllegalStateException("Invitation is not in a cancelable state");
}
// invitationId 로 사용자 이름 조회, 실패시 id 를 이름으로 대체
String name = portalUserRepository.findById(invitation.getInvitationEmail())
.map(PortalUser::getUserName)
.orElse(invitation.getInvitationEmail());
invitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(invitation);
// name 이 이메일 일 경우 @ 앞부분 만 사용 (안하면 암호화 해야함...)
if (name.contains("@")) {
name = name.substring(0, name.indexOf("@"));
// 알림 수신 미동의 시 취소 알림 메시지 발송은 스킵
if (!notifyConsent) {
return;
}
invitation.setStatus(InvitationStatus.CANCELED);
// invitationMobile 로 사용자 이름 조회, 실패시 마스킹된 번호로 대체
String name = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
.stream().findFirst()
.map(PortalUser::getUserName)
.orElse(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
messageHandlerService.publishEvent(UserInvitationCancelEvent.KEY,
MessageRecipient.builder()
@@ -260,8 +344,6 @@ public class UserManFacade {
}}
);
userInvitationRepository.save(invitation);
}
public void inactivateUser(PortalAuthenticatedUser admin, String userId) {
@@ -298,8 +380,8 @@ public class UserManFacade {
.stream()
.map(invitation -> {
PortalUserDTO dto = portalUserMapper.pendingUserVO(invitation);
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(invitation.getInvitationEmail()));
dto.setEmailAddr(null); // Clear unmasked email
dto.setMaskedMobileNumber(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
dto.setMobileNumber(null); // Clear unmasked mobile
return dto;
})
.collect(Collectors.toList());
@@ -328,32 +410,32 @@ public class UserManFacade {
existingInvitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(existingInvitation);
// 새로운 초대 생성 및 발송
sendInvitation(sender, existingInvitation.getInvitationEmail());
// 새로운 초대 생성 및 발송 (재발송 시 최초 동의값을 알 수 없으므로 발송함을 기본으로)
sendInvitation(sender, existingInvitation.getInvitationMobile(), true);
}
/**
* 중복 초대 확인
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
* 같은 기관에서 같은 휴대폰 번호로 이미 PENDING 상태인 초대가 있는지 확인
*/
private void checkDuplicateInvitation(String orgId, String emailAddr) {
private void checkDuplicateInvitation(String orgId, String mobile) {
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
emailAddr, orgId, InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndOrgIdAndStatus(
mobile, orgId, InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
throw new IllegalArgumentException("이미 초대가 진행 중인 휴대폰 번호입니다.");
}
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> otherOrgPendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
emailAddr, InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
mobile, InvitationStatus.PENDING);
if (otherOrgPendingInvitation.isPresent()
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
throw new IllegalArgumentException("해당 휴대폰 번호는 다른 기관에서 초대 진행 중입니다.");
}
}
@@ -137,12 +137,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
}
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
//
// if(existingUser != null) {
// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
// }
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
@@ -150,7 +149,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_IND);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
@@ -173,6 +172,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"입력값을 확인해 주세요.");
}
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
if(existingUser != null) {
@@ -190,7 +195,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.deleteUserAgreements(newUser.getId()); //이전에 동의한 내역은 삭제
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
invitation.setStatus(UserInvitationEnums.InvitationStatus.COMPLETED);
invitation.setCompleteDate(LocalDateTime.now());
@@ -242,7 +247,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
try {
if ("accept".equals(action)) {
// 수락 처리
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail())
PortalUser user = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다."));
// 서비스 계층에 위임
@@ -253,7 +259,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
userInvitationRepository.save(invitation);
agreementsFacade.deleteUserAgreements(user.getId());
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT);
ValidationResponse response = new ValidationResponse();
response.setValid(true);
@@ -15,7 +15,7 @@ public abstract class PortalUserMapper implements GenericMapper<PortalUserDTO, P
public abstract PortalAuthenticatedUser portalUserToAuthenticatedUser(PortalUser portalUser);
@Mapping(target = "emailAddr", source = "invitationEmail")
@Mapping(target = "mobileNumber", source = "invitationMobile")
public abstract PortalUserDTO pendingUserVO(UserInvitation invitation);
public PortalUserDTO toDTO(PortalUser portalUser) {
@@ -122,37 +122,6 @@ public class PortalUserAuthService implements UserDetailsService {
messageHandlerService.publishEvent(UserPasswordResetEvent.KEY, recipient, params);
}
public void resendVerificationEmail(String loginId) {
PortalUser user = portalUserRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ACTIVE)) {
throw new IllegalArgumentException("이미 인증이 완료된 계정입니다.");
}
// Enum을 직접 전달
List<MessageRequest> messageRequests = messageRequestRepository.findByEmailAndMessageCode(
user.getLoginId(), MessageCode.USER_VERIFICATION_EMAIL
);
if (!messageRequests.isEmpty()) {
messageRequestRepository.deleteAll(messageRequests);
}
HashMap<String, Object> params = new HashMap<>();
String tokenValue = user.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + user.getId();
try {
String encToken = encryptionUtil.encrypt(tokenValue);
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
MessageRecipient recipient = createMessageRecipient(user);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new SystemException("암호화 모듈 오류");
}
}
private MessageRecipient createMessageRecipient(PortalUser user) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(user.getUserName());
@@ -24,6 +24,12 @@ import org.springframework.util.StringUtils;
@Service
@Transactional
public class PortalUserService {
/** PortalProperty 그룹명 */
private static final String PORTAL_PROPERTY_GROUP = "Portal";
/** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */
private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow";
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final PasswordEncoder passwordEncoder;
@@ -68,6 +74,33 @@ public class PortalUserService {
return portalUserRepository.existsByLoginId(normalizedLoginId);
}
/**
* 휴대폰 번호 중복 여부 확인.
* mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다.
* (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다)
*/
public boolean existsByMobileNumber(String mobileNumber) {
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
return false;
}
return portalUserRepository.existsByMobileNumber(mobileNumber.trim());
}
/**
* 회원가입 시 휴대폰 번호 중복 차단 활성 여부.
* 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다.
* - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성
* - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false)
*/
@Transactional(readOnly = true)
public boolean isMobileDuplicateCheckEnabled() {
String allow = portalPropertyService
.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP)
.getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false");
boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow);
return !allowed;
}
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
return portalUserRepository.countByPortalOrg(portalOrg) > 1;
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -21,6 +22,9 @@ public class GlobalControllerAdvice {
@Autowired
private PortalProperties portalProperties;
@Autowired
private UserSessionService userSessionService;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -58,4 +62,12 @@ public class GlobalControllerAdvice {
}
return null;
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
return userSessionService.getSessionTimeoutMinutes();
}
}
@@ -1,6 +1,8 @@
package com.eactive.apim.portal.common.exception;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -9,6 +11,8 @@ import org.springframework.web.servlet.ModelAndView;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import java.io.PrintWriter;
import java.io.StringWriter;
/**
* Created by Sungpil Hyun
@@ -16,6 +20,11 @@ import javax.servlet.http.HttpServletRequest;
@Controller
public class PortalErrorController implements ErrorController {
private final Environment environment;
public PortalErrorController(Environment environment) {
this.environment = environment;
}
@RequestMapping(value = "/error", method = {RequestMethod.POST, RequestMethod.GET})
public ModelAndView handleError(HttpServletRequest request) {
@@ -27,10 +36,134 @@ public class PortalErrorController implements ErrorController {
}
ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("status", status);
if (isProd()) {
// prod 환경에서는 내부 오류 정보를 노출하지 않고 이전과 동일한 일반 안내 메시지만 표시
modelAndView.addObject("errorTitle", "일시적인 장애로 서비스가 중단되었습니다.");
modelAndView.addObject("errorDescription", "서비스 이용에 불편을 드려 죄송합니다. 빠른 서비스를 제공할 수 있게 준비하겠습니다.");
} else {
modelAndView.addObject("errorTitle", resolveTitle(status));
modelAndView.addObject("errorDescription", resolveDescription(status));
modelAndView.addObject("errorMessage", resolveErrorMessage(request, status));
modelAndView.addObject("activeProfile", resolveActiveProfile());
// include-stacktrace=always(로컬 디버깅 프로파일)일 때만 화면에 스택 트레이스까지 노출
if (isErrorDetailEnabled()) {
Throwable throwable = resolveThrowable(request);
if (throwable != null) {
modelAndView.addObject("errorException", throwable.getClass().getName());
modelAndView.addObject("errorStackTrace", stackTraceAsString(throwable));
}
}
}
return modelAndView;
}
return new ModelAndView("redirect:/");
}
private boolean isProd() {
return environment.acceptsProfiles(Profiles.of("prod"));
}
/**
* server.error.include-stacktrace=always 인 경우에만 화면에 스택 트레이스를 노출한다.
* (로컬 디버깅 프로파일에서만 해당 값을 always 로 설정 → 운영/스테이징에는 노출되지 않음)
*/
private boolean isErrorDetailEnabled() {
String includeStacktrace = environment.getProperty("server.error.include-stacktrace", "never");
return "always".equalsIgnoreCase(includeStacktrace);
}
private String resolveActiveProfile() {
String[] activeProfiles = environment.getActiveProfiles();
if (activeProfiles.length == 0) {
return "default";
}
return String.join(", ", activeProfiles);
}
private Throwable resolveThrowable(HttpServletRequest request) {
Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
if (exception instanceof Throwable) {
return (Throwable) exception;
}
return null;
}
private String stackTraceAsString(Throwable throwable) {
StringWriter stringWriter = new StringWriter();
throwable.printStackTrace(new PrintWriter(stringWriter));
return stringWriter.toString();
}
private String resolveTitle(Object status) {
HttpStatus httpStatus = resolveHttpStatus(status);
if (httpStatus == null) {
return "서비스 처리 중 오류가 발생했습니다.";
}
if (httpStatus == HttpStatus.FORBIDDEN) {
return "접근이 거부되었습니다.";
}
if (httpStatus == HttpStatus.NOT_FOUND) {
return "요청하신 페이지를 찾을 수 없습니다.";
}
if (httpStatus.is4xxClientError()) {
return "요청을 처리할 수 없습니다.";
}
if (httpStatus.is5xxServerError()) {
return "서비스 처리 중 오류가 발생했습니다.";
}
return httpStatus.getReasonPhrase();
}
private String resolveDescription(Object status) {
HttpStatus httpStatus = resolveHttpStatus(status);
if (httpStatus == HttpStatus.FORBIDDEN) {
return "요청이 보안 검증을 통과하지 못했습니다.";
}
if (httpStatus == HttpStatus.NOT_FOUND) {
return "주소가 변경되었거나 삭제되었을 수 있습니다.";
}
if (httpStatus != null && httpStatus.is5xxServerError()) {
return "잠시 후 다시 시도해 주세요.";
}
return "요청 내용을 확인한 뒤 다시 시도해 주세요.";
}
private String resolveErrorMessage(HttpServletRequest request, Object status) {
Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
HttpStatus httpStatus = resolveHttpStatus(status);
StringBuilder message = new StringBuilder();
message.append("HTTP ").append(status);
if (httpStatus != null) {
message.append(" ").append(httpStatus.getReasonPhrase());
}
if (requestUri != null) {
message.append(" / 요청 경로: ").append(requestUri);
}
Object exceptionMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
if (exceptionMessage != null && !exceptionMessage.toString().trim().isEmpty()) {
message.append(" / 원인: ").append(exceptionMessage);
} else if (httpStatus == HttpStatus.FORBIDDEN) {
message.append(" / 원인: 접근 권한 또는 CSRF 토큰을 확인하세요.");
}
return message.toString();
}
private HttpStatus resolveHttpStatus(Object status) {
if (status instanceof Integer) {
return HttpStatus.resolve((Integer) status);
}
try {
return HttpStatus.resolve(Integer.parseInt(status.toString()));
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,194 @@
package com.eactive.apim.portal.common.migration;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import javax.servlet.http.HttpServletRequest;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
*
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
*
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
*
* <pre>
* # 미리보기(변경 안 함)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
* # 실제 실행 (PII 컬럼)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
* </pre>
*/
@Slf4j
@RestController
@RequestMapping("/internal/migration")
public class LegacyEncryptionMigrationController {
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
);
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
private static final List<String> AUDIT_TABLES = Arrays.asList(
"PTL_USER", "ptl_faq", "ptl_org",
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
);
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
private final JdbcTemplate jdbcTemplate;
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
}
@PostMapping("/encrypt-legacy")
@Transactional("transactionManager")
public Map<String, Object> encryptLegacy(HttpServletRequest request,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "false") boolean includeAudit) {
assertLocalOnly(request);
assertNotBypass();
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
if (includeAudit) {
for (String table : AUDIT_TABLES) {
targets.add(new TargetTable(table, AUDIT_COLUMNS));
}
}
List<Map<String, Object>> results = new ArrayList<>();
int totalChanged = 0;
for (TargetTable target : targets) {
for (String column : target.columns) {
Map<String, Object> r = processColumn(target.table, column, dryRun);
results.add(r);
totalChanged += (int) r.get("changed");
}
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
response.put("includeAudit", includeAudit);
response.put("totalChanged", totalChanged);
response.put("results", results);
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
return response;
}
/**
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
*/
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("table", table);
r.put("column", column);
List<String> values;
try {
values = jdbcTemplate.queryForList(
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
String.class);
} catch (Exception e) {
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
r.put("distinct", 0);
r.put("changed", 0);
r.put("error", e.getMessage());
return r;
}
int changed = 0;
for (String value : values) {
String normalized;
try {
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
} catch (Exception e) {
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
continue;
}
if (normalized != null && !normalized.equals(value)) {
if (!dryRun) {
jdbcTemplate.update(
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
normalized, value);
}
changed++;
}
}
r.put("distinct", values.size());
r.put("changed", changed);
return r;
}
/**
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
*/
private void assertNotBypass() {
if (converter.isBypassMode()) {
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
throw new ResponseStatusException(HttpStatus.CONFLICT,
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
}
}
/**
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
*/
private void assertLocalOnly(HttpServletRequest request) {
String remote = request.getRemoteAddr();
boolean localAddr = "127.0.0.1".equals(remote)
|| "0:0:0:0:0:0:0:1".equals(remote)
|| "::1".equals(remote);
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
if (!localAddr || viaProxy) {
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
}
}
private static final class TargetTable {
final String table;
final List<String> columns;
TargetTable(String table, List<String> columns) {
this.table = table;
this.columns = columns;
}
}
}
@@ -1,10 +1,24 @@
package com.eactive.apim.portal.common.util;
import java.util.Arrays;
/**
* 문자열 마스킹 처리를 위한 유틸리티 클래스
*
* <p>보안아키텍처 표준 마스킹 규칙(elink-online-core {@code MaskingUtils})과 동일한 알고리즘을
* 구현한다. eapim-portal은 elink-online-core에 의존하지 않으므로 로직을 직접 포함한다.
* 소유자 본인이 자신의 정보를 조회하는 경우 마스킹하지 않는 owner 인지 동작은 유지한다.</p>
*
* <pre>
* 이름 2자: 뒤 1자리(가나→가*) / 3자 이상: 앞뒤 제외 가운데 전체(가나다라→가**라)
* 이메일 아이디 앞2·뒤2 마스킹, 4자 이하 전체(test→****, test123→**st1**)
* 전화 2·3번째 세그먼트 각각 뒤 2자리(010-1234-1234→010-12**-12**)
* </pre>
*/
public class StringMaskingUtil {
private static final char MASK = '*';
// ID 비교
private static boolean isOwner(String ownerId, String currentUserId) {
return currentUserId != null && ownerId != null && currentUserId.equals(ownerId);
@@ -21,8 +35,15 @@ public class StringMaskingUtil {
return name;
}
return name.length() > 1 ?
name.substring(0, name.length() - 1) + "*" : name;
int len = name.length();
if (len == 1) {
return name;
}
if (len == 2) {
return name.charAt(0) + String.valueOf(MASK);
}
// 3자 이상: 첫 자 + 가운데 전체 마스킹 + 마지막 자
return name.charAt(0) + stars(len - 2) + name.charAt(len - 1);
}
// 이메일 마스킹 처리
@@ -31,32 +52,32 @@ public class StringMaskingUtil {
return email;
}
String[] parts = email.split("@", 2);
if (parts.length != 2) {
return email;
int atIdx = email.indexOf('@');
String local = email.substring(0, atIdx);
String domain = email.substring(atIdx);
if (local.length() <= 4) {
return stars(local.length()) + domain;
}
String localPart = parts[0];
String maskedLocal;
if (localPart.length() <= 2) {
maskedLocal = "***";
} else {
maskedLocal = localPart.substring(0, localPart.length() - 3) + "***";
}
return maskedLocal + "@" + parts[1];
return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain;
}
// 휴대폰 번호 마스킹 처리
// 휴대폰/전화/FAX 번호 마스킹 처리
public static String maskMobileNumber(String number, String ownerId, String currentUserId) {
if (!isValidString(number) || isOwner(ownerId, currentUserId)) {
return number;
}
String[] parts = number.split("-", 3);
return parts.length == 3 ?
parts[0] + "-***-" + parts[2] : number;
boolean hasHyphen = number.contains("-");
String normalized = hasHyphen ? number : normalizePhoneNumber(number);
String[] parts = normalized.split("-");
if (parts.length == 3) {
String m0 = parts[0];
String m1 = maskSegmentTail(parts[1], 2);
String m2 = maskSegmentTail(parts[2], 2);
return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2;
}
return number;
}
// 기존 메서드 오버로딩 (하위 호환성)
@@ -71,4 +92,41 @@ public class StringMaskingUtil {
public static String maskMobileNumber(String number) {
return maskMobileNumber(number, null, null);
}
// =========================================================================
// private helpers
// =========================================================================
private static String stars(int count) {
if (count <= 0) return "";
char[] arr = new char[count];
Arrays.fill(arr, MASK);
return new String(arr);
}
private static String maskSegmentTail(String segment, int count) {
if (segment.length() <= count) return stars(segment.length());
return segment.substring(0, segment.length() - count) + stars(count);
}
/**
* 한국 전화번호(숫자만) → XXX-XXXX-XXXX 형식으로 정규화
* <pre>
* 02 + 7자리 → 02-XXX-XXXX
* 02 + 8자리 → 02-XXXX-XXXX
* 0XX + 7자리 → 0XX-XXX-XXXX
* 0XX + 8자리 → 0XX-XXXX-XXXX
* </pre>
*/
private static String normalizePhoneNumber(String digits) {
int len = digits.length();
if (digits.startsWith("02")) {
if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5);
if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6);
} else if (digits.startsWith("0")) {
if (len == 10) return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
if (len == 11) return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
}
return digits;
}
}
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
persistenceUnit = "gateway";
}
// 개발 환경용
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
if (env.matchesProfiles("local")) {
properties.put("hibernate.hbm2ddl.auto", "validate");
properties.put("hibernate.hbm2ddl.auto", "none");
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
@@ -49,6 +50,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -94,8 +96,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getLoginId(), InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
user.getMobileNumber(), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
UserInvitation invitation = pendingInvitation.get();
@@ -112,6 +114,12 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
}
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 로그인 성공 시 세션 정보 로깅
logLoginSuccess(request, session, username);
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -10,10 +11,9 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -33,15 +33,30 @@ public class PortalConfigSecurity {
private final PortalLogoutSuccessHandler logoutSuccessHandler;
private final SessionValidationFilter sessionValidationFilter;
@Autowired
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
PortalAuthenticationManager portalAuthenticationManager,
PortalLogoutSuccessHandler logoutSuccessHandler) {
PortalLogoutSuccessHandler logoutSuccessHandler,
SessionValidationFilter sessionValidationFilter) {
this.authenticationFailureHandler = authenticationFailureHandler;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.portalAuthenticationManager = portalAuthenticationManager;
this.logoutSuccessHandler = logoutSuccessHandler;
this.sessionValidationFilter = sessionValidationFilter;
}
/**
* {@link SessionValidationFilter}가 Spring Security 필터 체인에서만 동작하도록
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
*/
@Bean
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
@@ -64,6 +79,13 @@ public class PortalConfigSecurity {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
try {
// CSRF 토큰을 쿠키(XSRF-TOKEN) 대신 HTTP 세션에 저장한다.
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
http
.authenticationManager(portalAuthenticationManager)
.formLogin(form -> form
@@ -75,21 +97,23 @@ public class PortalConfigSecurity {
.successHandler(authenticationSuccessHandler))
.logout(logout -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/actionLogout.do", "GET"))
// LogoutHandler 는 기본 SecurityContextLogoutHandler(세션 무효화)보다 먼저 실행된다.
// 세션이 살아있는 이 시점에 DB 세션 레코드를 정리해야 중복세션 오탐("이미 접속중")을 막는다.
.addLogoutHandler(logoutSuccessHandler)
.logoutSuccessHandler(logoutSuccessHandler))
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // Allow only one session per user
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
.expiredUrl("/login?expired") // Redirect when session expires
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
.and()
.sessionFixation()
.changeSessionId()
);
)
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
} catch (Exception e) {
@@ -97,11 +121,6 @@ public class PortalConfigSecurity {
}
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.converter.EnabledStatusConverter;
import java.util.List;
import java.util.concurrent.TimeUnit;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.format.FormatterRegistry;
@@ -18,10 +21,13 @@ import org.springframework.web.filter.CharacterEncodingFilter;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.multipart.support.MultipartFilter;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
import org.springframework.web.servlet.resource.VersionResourceResolver;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
@@ -32,6 +38,16 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public static final String ERROR = "error";
private final Environment environment;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@Value("${app.resource-versioning.enabled:true}")
private boolean resourceVersioningEnabled;
public PortalConfigWebDispatcherServlet(Environment environment) {
this.environment = environment;
}
@Bean
@@ -61,16 +77,45 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
}
registry.addResourceHandler("/css/**").addResourceLocations("/css/", "classpath:/static/css/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/webfonts/**").addResourceLocations("/webfonts/", "classpath:/static/webfonts/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/font/**").addResourceLocations("/font/", "classpath:/static/font/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/html/**").addResourceLocations("/html/", "classpath:/static/html/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
/**
* 정적자원 핸들러 공통 등록.
*
* <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 를 체인 앞단에 추가하여
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS))
.resourceChain(true);
if (isResourceVersioningEnabled()) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
chain.addResolver(new PathResourceResolver());
}
/**
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
*/
private boolean isResourceVersioningEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceVersioningEnabled;
}
@@ -123,4 +168,13 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
/**
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 로 치환하기 위한 필터.
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 빈 등록한다.
*/
@Bean
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
return new ResourceUrlEncodingFilter();
}
}
@@ -1,10 +1,12 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
@@ -18,20 +20,37 @@ import java.time.format.DateTimeFormatter;
import java.util.Enumeration;
/**
* 로그아웃 성공 시 세션 정보를 로깅하는 핸들러
* 로그아웃 처리 핸들러.
*
* <p>DB 세션 레코드 정리와 로깅은 {@link LogoutHandler#logout}에서 수행한다. 이 시점은 기본
* {@code SecurityContextLogoutHandler}가 HTTP 세션을 무효화하기 <b>전</b>이라 세션이 유효하다.
* (성공 핸들러 {@link #onLogoutSuccess}는 무효화 <b>후</b>에 실행되어 {@code getSession(false)}가
* null 이 되므로, 거기서 정리하면 DB 행이 남아 재로그인 시 "이미 접속중" 오탐이 발생한다.)
*/
@Component
public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessHandler {
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private final UserSessionService userSessionService;
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
this.userSessionService = userSessionService;
}
/**
* 세션 무효화 전에 실행되어 DB 세션 레코드를 정리하고 로그아웃 정보를 로깅한다.
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
public void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
HttpSession session = request.getSession(false);
if (session != null) {
// DB 세션 레코드 정리 (중복세션 오탐 방지)
userSessionService.removeSession(session.getId());
StringBuilder logMessage = new StringBuilder();
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
@@ -97,7 +116,14 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
sessionLogger.info(logMessage.toString());
}
}
/**
* 로그아웃(세션 무효화) 완료 후 메인으로 리다이렉트한다.
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
response.sendRedirect(request.getContextPath() + "/");
}
}
@@ -1,54 +0,0 @@
package com.eactive.apim.portal.djb.community.qna.comment.event;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageEventHandler;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.template.service.MessageSendEvent;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class InquiryCommentCreatedAdminEvent implements MessageEventHandler {
public static final MessageCode KEY = MessageCode.INQUIRY_COMMENT_CREATED;
@Override
public MessageCode getKey() {
return KEY;
}
@Override
public boolean allowAdditionalRecipients() {
return true;
}
@Override
public String getDisplayName() {
return "Q&A 댓글 등록 알림";
}
@Override
public String getDescription() {
return "<div>사용 가능 변수: </div><br/>"
+ "<div> - inquiryId: 문의 ID </div><br/>"
+ "<div> - inquirySubject: 문의 제목 </div><br/>"
+ "<div> - commentContent: 댓글 본문 </div><br/>"
+ "<div> - writerName: 작성자 이름 </div><br/>";
}
@Override
public MessageSendEvent createEvent(Object source, MessageRecipient recipient, Map<String, Object> params) {
Map<String, String> requestParams = new HashMap<>();
requestParams.put("inquiryId", toStr(params.get("inquiryId")));
requestParams.put("inquirySubject", toStr(params.get("inquirySubject")));
requestParams.put("commentContent", toStr(params.get("commentContent")));
requestParams.put("writerName", toStr(params.get("writerName")));
return new MessageSendEvent(source, KEY, recipient, requestParams);
}
private String toStr(Object value) {
return value == null ? "" : value.toString();
}
}
@@ -20,7 +20,7 @@ import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class InquiryAdminNotifier {
public class CommunityAdminNotifier {
private final UserInfoRepository userInfoRepository;
private final MessageHandlerService messageHandlerService;
@@ -42,7 +42,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
private final InquiryAdminNotifier adminNotifier;
private final CommunityAdminNotifier adminNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.djb.community.qna.constant;
public final class DjbInquiryStatus {
public static final String PENDING = "PENDING";
public static final String IN_PROGRESS = "IN_PROGRESS";
public static final String RESPONDED = "RESPONDED";
public static final String CLOSED = "CLOSED";
private DjbInquiryStatus() {
+1 -1
View File
@@ -39,7 +39,7 @@ gateway:
portal:
auth-virtual-code: 654321
# auth-virtual-code: 654321
test-auth-notice-enabled: true
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
+3
View File
@@ -11,6 +11,9 @@ spring:
cachecontrol:
max-age: 86400
public: true
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
jpa:
properties:
hibernate:
+21 -1
View File
@@ -24,6 +24,8 @@ spring:
default-page-size: '10'
jpa:
open-in-view: false
hibernate:
ddl-auto: none
web:
resources:
@@ -33,6 +35,10 @@ spring:
max-age: 0
must-revalidate: true
no-cache: true
# 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
# @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
jta:
enabled: true
@@ -49,6 +55,14 @@ spring:
cache: false
main:
allow-bean-definition-overriding: true
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
app:
resource-versioning:
enabled: true
security:
basic:
enabled: false
@@ -235,12 +249,15 @@ page:
corp:
name: "법인회원가입"
path: "/signup/portalOrg"
verification_email:
name: "이메일 인증"
path: "/signup/verification-email"
agreements:
name: 약관
path: "#"
children:
terms:
name: "이용약관 및 개인정보처리방침"
name: "이용약관"
path: "/agreements/terms"
about:
name: 안내
@@ -310,6 +327,9 @@ page:
mypage:
name: "내 정보 관리"
path: "/mypage"
verification_email:
name: "이메일 인증"
path: "/mypage/verification-email"
user_list:
name: "이용자 관리"
path: "/users"
-66
View File
@@ -5,9 +5,6 @@
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
<!-- <springProfile name="gf63">-->
<!-- <property name="LOG_PATH" value="d:/kjb-logs/${inst.Name:-devSvr00}"/>-->
<!-- </springProfile>-->
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
@@ -37,18 +34,6 @@
</encoder>
</appender>
<appender name="NEED_FIX" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/need-fix.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/need-fix.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - %msg%n</pattern>
</encoder>
</appender>
<appender name="HTTP_SESSION" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/http-session.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
@@ -66,10 +51,6 @@
</appender>
<logger name="eapim.portal.needfix" level="INFO" additivity="false">
<appender-ref ref="NEED_FIX" />
</logger>
<logger name="eapim.portal.session" level="INFO" additivity="false">
<appender-ref ref="HTTP_SESSION" />
</logger>
@@ -96,51 +77,4 @@
<springProfile name="stage,dev">
</springProfile>
<springProfile name="gf63">
<logger name="sun.rmi" level="INFO"/>
<logger name="javax" level="INFO"/>
<logger name="jdk.event.security" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="com.atomikos" level="INFO"/>
<logger name="org.apache" level="INFO"/>
<logger name="org.codehaus.groovy.vmplugin.VMPluginFactory" level="INFO"/>
<!-- <logger name="org.hibernate" level="INFO" additivity="false"/>-->
<logger name="org.thymeleaf.TemplateEngine" level="INFO"/>
<!-- <logger name="org.springframework" level="INFO" />-->
<logger name="_org.springframework.web" level="INFO" />
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="org.hibernate.engine.jdbc.spi.SqlExceptionHelper" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="com.eactive.eapim" level="DEBUG"/>
<logger name="kjb.safedb" level="DEBUG" additivity="false">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
</logger>
<root level="INFO">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
</root>
<!-- <root level="DEBUG">-->
<!-- <appender-ref ref="ROLLING"/>-->
<!-- <appender-ref ref="CONSOLE"/>-->
<!-- </root>-->
</springProfile>
</configuration>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 380 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 380 KiB

@@ -1,3 +1,4 @@
<svg width="1920" height="314" viewBox="0 0 1920 314" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1920 314H0V0.447266C158.076 115.873 527.685 196.879 958.538 196.999H960.462C1391.87 196.878 1761.87 115.662 1919.61 0H1920V314Z" fill="#CFF2FF"/>
<svg width="1920" height="318" viewBox="0 0 1920 318" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1920 318H0V64.4473C158.076 179.873 527.685 260.879 958.538 260.999H960.462C1391.87 260.878 1761.87 179.662 1919.61 64H1920V318Z" fill="#D3EFFB"/>
<path d="M1920 281H0V13.71C158.194 148.461 528.235 243 959.499 243C1391.68 243 1762.37 148.059 1920 12.8525V0H1920V281ZM0 0V13.71H-0.000976562V0H0Z" fill="#CFF2FF" fill-opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 174 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 133 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 159 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 239 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 537 KiB

@@ -14,8 +14,9 @@
const counterEl = document.getElementById('djbCommentCharCounter');
function getCsrfToken() {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
const meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
function fetchJson(url, options) {
@@ -44,7 +45,15 @@
function formatDate(value) {
if (!value) return '';
try {
const d = new Date(value);
var d;
if (Array.isArray(value)) {
// Jackson LocalDateTime 직렬화(timestamp 배열): [year, month(1-base), day, hour, minute, second, nano]
var y = value[0], mo = value[1] || 1, da = value[2] || 1;
var h = value[3] || 0, mi = value[4] || 0, s = value[5] || 0;
d = new Date(y, mo - 1, da, h, mi, s);
} else {
d = new Date(value);
}
if (isNaN(d.getTime())) return '';
const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '.' + pad(d.getMonth() + 1) + '.' + pad(d.getDate())
@@ -70,6 +79,9 @@
li.innerHTML = ''
+ '<div class="djb-comment-meta">'
+ ' <div class="djb-comment-meta-left">'
+ ' <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 2px; opacity: 0.5;">'
+ ' <path d="M5 3v5a2 2 0 0 0 2 2h5 M9 7l3 3-3 3" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
+ ' </svg>'
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
+ (c.adminYn === 'Y' ? ' <span class="djb-comment-admin-badge">관리자</span>' : '')
+ ' </div>'
@@ -355,16 +355,11 @@ const customPopups = {
$('#emailValidationPopup').hide();
});
},
showEmailResendPopup: function (message, loginId) {
$('#emailResendMessage').html(customPopups._sanitizeHtml(message));
$('#userLoginId').val(loginId);
$('#emailResend').show();
},
/**
* 사용자 초대 팝업 표시
* @param {Object} options - 팝업 옵션
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: email)
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: mobile, notifyConsent)
* @param {Function} options.onCancel - 취소 버튼 클릭 시 호출되는 콜백 (선택사항)
*/
showUserInvite: function (options) {
@@ -374,7 +369,8 @@ const customPopups = {
const onCancel = options.onCancel;
// 입력 필드 및 에러 초기화
$('#userInviteEmailInput').val('').removeClass('error');
$('#userInviteMobileInput').val('').removeClass('error');
$('#userInviteNotifyConsent').prop('checked', false);
$('#userInvitePopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -389,28 +385,29 @@ const customPopups = {
// 입력 필드에 포커스
setTimeout(function() {
$('#userInviteEmailInput').focus();
$('#userInviteMobileInput').focus();
}, 350);
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
$('#userInvitePopupConfirmButton').off('click').on('click', function () {
const email = $('#userInviteEmailInput').val().trim();
const mobile = $('#userInviteMobileInput').val().trim();
const notifyConsent = $('#userInviteNotifyConsent').is(':checked');
// 이메일 유효성 검사
if (!email) {
customPopups.showUserInviteError('이메일 주소를 입력해주세요.');
// 휴대폰 번호 유효성 검사
if (!mobile) {
customPopups.showUserInviteError('휴대폰 번호를 입력해주세요.');
return;
}
// 이메일 형식 검증
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
customPopups.showUserInviteError('올바른 이메일 형식이 아닙니다.');
// 휴대폰 번호 형식 검증 (하이픈 유무 모두 허용)
const mobilePattern = /^01[016-9]-?\d{3,4}-?\d{4}$/;
if (!mobilePattern.test(mobile)) {
customPopups.showUserInviteError('올바른 휴대폰 번호 형식이 아닙니다.');
return;
}
if (typeof onConfirm === 'function') {
onConfirm(email);
onConfirm(mobile, notifyConsent);
}
});
@@ -431,14 +428,14 @@ const customPopups = {
});
// Enter 키 이벤트
$('#userInviteEmailInput').off('keypress').on('keypress', function (e) {
$('#userInviteMobileInput').off('keypress').on('keypress', function (e) {
if (e.which === 13) {
$('#userInvitePopupConfirmButton').click();
}
});
// 입력 시 에러 초기화
$('#userInviteEmailInput').off('input').on('input', function () {
$('#userInviteMobileInput').off('input').on('input', function () {
customPopups.clearUserInviteError();
});
},
@@ -460,14 +457,15 @@ const customPopups = {
$('body').css('overflow', '');
// 입력 필드 초기화
$('#userInviteEmailInput').val('').removeClass('error');
$('#userInviteMobileInput').val('').removeClass('error');
$('#userInviteNotifyConsent').prop('checked', false);
$('#userInvitePopupError').removeClass('show').text('');
// 이벤트 리스너 제거
$('#userInvitePopupConfirmButton').off('click');
$('#userInvitePopupCancelButton').off('click');
$('#userInvitePopupCloseButton').off('click');
$('#userInviteEmailInput').off('keypress').off('input');
$('#userInviteMobileInput').off('keypress').off('input');
},
/**
@@ -476,7 +474,7 @@ const customPopups = {
*/
showUserInviteError: function (message) {
$('#userInvitePopupError').text(message).addClass('show');
$('#userInviteEmailInput').addClass('error');
$('#userInviteMobileInput').addClass('error');
},
/**
@@ -484,7 +482,96 @@ const customPopups = {
*/
clearUserInviteError: function () {
$('#userInvitePopupError').removeClass('show');
$('#userInviteEmailInput').removeClass('error');
$('#userInviteMobileInput').removeClass('error');
},
/**
* 초대취소 팝업 표시 (알림 수신 동의 체크박스 포함)
* @param {Object} options - 팝업 옵션
* @param {string} options.target - 취소 대상 표시 문자열 (마스킹된 이메일/휴대폰)
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: notifyConsent)
* @param {Function} options.onCancel - 취소(닫기) 버튼 클릭 시 호출되는 콜백 (선택사항)
*/
showCancelInvitation: function (options) {
options = options || {};
const target = options.target || '';
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
// 대상 정보 및 체크박스 초기화
$('#cancelInvitationTarget').text(target);
$('#cancelInvitationNotifyConsent').prop('checked', false);
// 팝업 표시 (modal 구조 사용)
$('#cancelInvitationPopup').show();
setTimeout(function() {
$('#cancelInvitationModalBackdrop').addClass('show');
$('#cancelInvitationModal').addClass('show');
}, 10);
// Body 스크롤 방지
$('body').css('overflow', 'hidden');
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
$('#cancelInvitationPopupConfirmButton').off('click').on('click', function () {
const notifyConsent = $('#cancelInvitationNotifyConsent').is(':checked');
if (typeof onConfirm === 'function') {
onConfirm(notifyConsent);
}
});
// 닫기 버튼 이벤트
$('#cancelInvitationPopupCancelButton').off('click').on('click', function () {
customPopups.hideCancelInvitation();
if (typeof onCancel === 'function') {
onCancel();
}
});
// X 버튼 이벤트
$('#cancelInvitationPopupCloseButton').off('click').on('click', function () {
customPopups.hideCancelInvitation();
if (typeof onCancel === 'function') {
onCancel();
}
});
// Escape 키 이벤트
$(document).off('keydown.cancelInvitation').on('keydown.cancelInvitation', function (e) {
if (e.key === 'Escape') {
customPopups.hideCancelInvitation();
if (typeof onCancel === 'function') {
onCancel();
}
}
});
},
/**
* 초대취소 팝업 숨기기
*/
hideCancelInvitation: function () {
// Modal 숨김 애니메이션
$('#cancelInvitationModalBackdrop').removeClass('show');
$('#cancelInvitationModal').removeClass('show');
// 애니메이션 완료 후 숨김
setTimeout(function() {
$('#cancelInvitationPopup').hide();
}, 300);
// Body 스크롤 복원
$('body').css('overflow', '');
// 체크박스 초기화
$('#cancelInvitationNotifyConsent').prop('checked', false);
// 이벤트 리스너 제거
$('#cancelInvitationPopupConfirmButton').off('click');
$('#cancelInvitationPopupCancelButton').off('click');
$('#cancelInvitationPopupCloseButton').off('click');
$(document).off('keydown.cancelInvitation');
},
/**
@@ -5,7 +5,8 @@
// Color Palette - Vibrant and Modern
// Primary colors
$primary-blue: #0049b4; // 광주은행 블루
$primary-color: #0049B4;
$primary-blue: #0049b4; // Vibrant blue
$secondary-blue: #c3dfea; // Deep blue
$accent-cyan: #00D4FF; // Sky blue accent
$accent-yellow: #FFD93D; // Yellow accent
@@ -35,7 +36,8 @@ $shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
$shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
// Typography
$font-family-primary: '원신한', 'OneShinhan', 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-primary: 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-heading: 'OneShinhan', 'Spoqa Han Sans Neo', 'SpoqaHanSans', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-mono: 'Fira Code', 'Courier New', monospace;
// Font sizes

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