Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37be9a361a | |||
| a91b56ed11 | |||
| 8d232734b1 | |||
| 06c18e437b | |||
| 97b9d2161a | |||
| 0c3b63a5b1 | |||
| 582a3e0f69 | |||
| c1ae12e1ca | |||
| 77aea79363 | |||
| aa5e769148 | |||
| ee8e9e3529 | |||
| 82948a13ce | |||
| f79e528fec | |||
| 6f776ed72a | |||
| e242d3ed06 | |||
| d7abe2c2e5 | |||
| 84d92fced9 | |||
| 53468c3b83 | |||
| 198f6fc82a | |||
| 21c500610c | |||
| 687d4798d4 | |||
| 5adfd42751 | |||
| 6694715195 | |||
| fc96c13df2 | |||
| 7265ad6589 | |||
| 02f9d41334 | |||
| fe4b176512 | |||
| 17c27e4398 | |||
| 10ff5d6f2f | |||
| f671f491b7 | |||
| 3d68589ae7 | |||
| 7268aef211 | |||
| 85b2f01ce0 | |||
| 0f255d7337 | |||
| a8323e78f5 | |||
| fa8fce4f2d | |||
| 4e7044b9fe | |||
| bddd7e3f85 |
@@ -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"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ plugins {
|
||||
id 'war'
|
||||
id 'eclipse'
|
||||
id 'idea'
|
||||
id 'org.cyclonedx.bom' version '3.2.4'
|
||||
id 'org.springframework.boot' version '2.7.18'
|
||||
id 'io.spring.dependency-management' version '1.1.3'
|
||||
}
|
||||
@@ -150,11 +151,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 +171,7 @@ test {
|
||||
|
||||
bootWar {
|
||||
archiveFileName = "eapim-portal-boot.war"
|
||||
mainClass = 'com.eactive.apim.portal.PortalApplication'
|
||||
}
|
||||
|
||||
war {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
./gradlew bootWar
|
||||
docker build -t eactive-portal .
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the war file is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
WAR_FILE="$1"
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
||||
BACKUP_DIR="/app/apim/backup"
|
||||
|
||||
# Create backup directory if not exists
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup the existing directory
|
||||
if [ -d "$DEST_DIR" ]; then
|
||||
echo "Backing up existing directory..."
|
||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
# Create destination directory if not exists
|
||||
mkdir -p $DEST_DIR
|
||||
|
||||
# Unzip the file to the dedicated location
|
||||
echo "Extracting WAR file to $DEST_DIR..."
|
||||
unzip $WAR_FILE -d $DEST_DIR
|
||||
|
||||
echo "deploy successful"
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if the war file is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 /path/to/DEVPortal.war"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
WAR_FILE="$1"
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
DEST_DIR="/app/apim/deploy/DVPWeb"
|
||||
STATIC_DIR="/app/apim/deploy/static"
|
||||
BACKUP_DIR="/app/apim/backup"
|
||||
|
||||
# Create backup directory if not exists
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup the existing directory
|
||||
if [ -d "$STATIC_DIR" ]; then
|
||||
echo "Backing up existing static directory..."
|
||||
mv $STATIC_DIR "${BACKUP_DIR}/dvp_static_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
if [ -d "$DEST_DIR" ]; then
|
||||
echo "Backing up existing directory..."
|
||||
mv $DEST_DIR "${BACKUP_DIR}/dvp_${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
|
||||
# Create destination directory if not exists
|
||||
mkdir -p $DEST_DIR
|
||||
mkdir -p $STATIC_DIR
|
||||
|
||||
# unzip the file to the dedicated location
|
||||
echo "Extracting WAR file to $DEST_DIR..."
|
||||
unzip $WAR_FILE -d $DEST_DIR
|
||||
mv $DEST_DIR/static/* $STATIC_DIR/
|
||||
|
||||
echo "deploy successful"
|
||||
@@ -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 $@
|
||||
@@ -8,6 +8,9 @@
|
||||
"name": "eapim-portal",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"playwright": "^1.61.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.69.5"
|
||||
}
|
||||
@@ -349,6 +352,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
@@ -403,6 +420,36 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
"sass": "^1.69.5"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"playwright": "^1.61.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
|
||||
|
||||
@@ -31,4 +31,21 @@ public class ApprovalDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private LocalDateTime approvalDate;
|
||||
|
||||
private String expectEndDate; //예상완료일 (yyyyMMdd)
|
||||
|
||||
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
|
||||
public String getExpectEndDateDayOfWeek() {
|
||||
if (expectEndDate == null || expectEndDate.length() < 8) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return java.time.LocalDate
|
||||
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
.getDayOfWeek()
|
||||
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
|
||||
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class AuthNumberStorage {
|
||||
|
||||
private static final String HASH_ALGORITHM = "HmacSHA256";
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
/**
|
||||
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
|
||||
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
|
||||
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
|
||||
*/
|
||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
||||
private String hashPepper;
|
||||
|
||||
@Autowired
|
||||
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
|
||||
this.twoFactorAuthRepository = twoFactorAuthRepository;
|
||||
}
|
||||
|
||||
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
|
||||
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
|
||||
String lookupKey = hashRecipient(recipientKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
|
||||
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
|
||||
auth.setRecipientKey(lookupKey);
|
||||
twoFactorAuthRepository.save(auth);
|
||||
}
|
||||
|
||||
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
|
||||
return twoFactorAuthRepository.findByRecipient(recipientKey);
|
||||
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
|
||||
}
|
||||
|
||||
public void deleteAuthNumber(String recipientKey) {
|
||||
twoFactorAuthRepository.deleteById(recipientKey);
|
||||
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
|
||||
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
|
||||
*/
|
||||
private String hashRecipient(String recipient) {
|
||||
if (recipient == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HASH_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
|
||||
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
sb.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.eactive.apim.portal.apps.community.notice.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IncidentAffectedApiDTO {
|
||||
private String apiId;
|
||||
private String apiName;
|
||||
}
|
||||
@@ -8,11 +8,18 @@ import org.hibernate.validator.constraints.Length;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalNoticeDTO {
|
||||
|
||||
public static final String NOTICE_TYPE_NORMAL = "1";
|
||||
public static final String NOTICE_TYPE_MAINTENANCE = "2";
|
||||
public static final String NOTICE_TYPE_INCIDENT = "3";
|
||||
|
||||
private String id;
|
||||
|
||||
@NotEmpty(message = "제목을 입력해주세요.")
|
||||
@@ -29,9 +36,33 @@ public class PortalNoticeDTO {
|
||||
|
||||
private boolean useYn;
|
||||
|
||||
private String fixYn;
|
||||
|
||||
private String noticeType;
|
||||
|
||||
private String inquirerName;
|
||||
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private LocalDateTime lastModifiedDate;
|
||||
|
||||
// ───── 장애/점검 연동 필드 ─────
|
||||
private String summary;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime endAt;
|
||||
private String state;
|
||||
private String previousState;
|
||||
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
|
||||
|
||||
public boolean isIncidentType() {
|
||||
return NOTICE_TYPE_INCIDENT.equals(noticeType);
|
||||
}
|
||||
|
||||
public boolean isMaintenanceType() {
|
||||
return NOTICE_TYPE_MAINTENANCE.equals(noticeType);
|
||||
}
|
||||
|
||||
public boolean isIncidentOrMaintenance() {
|
||||
return isIncidentType() || isMaintenanceType();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.eactive.apim.portal.apps.community.notice.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.community.notice.dto.IncidentAffectedApiDTO;
|
||||
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
|
||||
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
|
||||
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -12,7 +16,10 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service("portalNoticeFacade")
|
||||
@RequiredArgsConstructor
|
||||
@@ -20,12 +27,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
|
||||
private final PortalNoticeService portalNoticeService;
|
||||
private final PortalNoticeMapper portalNoticeMapper;
|
||||
private final DjbApistatusIncidentRepository incidentRepository;
|
||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
|
||||
@Override
|
||||
public List<PortalNoticeDTO> getLatestNotices() {
|
||||
PortalNoticeSearch search = new PortalNoticeSearch();
|
||||
Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdDate"));
|
||||
// Pageable pageable = Pageable.ofSize(3);
|
||||
Page<PortalNoticeDTO> notices = getNotices(search, pageable);
|
||||
return notices.getContent();
|
||||
}
|
||||
@@ -36,13 +44,47 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
Specification<PortalNotice> spec = Specification.where(search.buildSpecification())
|
||||
.and((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("useYn"),"Y"));
|
||||
|
||||
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, pageable);
|
||||
// 상단고정(fixYn='Y') 우선 정렬 — 호출자의 sort 앞에 결합 (admin Service 와 일관)
|
||||
Sort fixYnFirst = Sort.by(Sort.Direction.DESC, "fixYn");
|
||||
Pageable fixedPageable = PageRequest.of(
|
||||
pageable.getPageNumber(),
|
||||
pageable.getPageSize(),
|
||||
fixYnFirst.and(pageable.getSort())
|
||||
);
|
||||
|
||||
Page<PortalNotice> noticesPage = portalNoticeService.getNotices(spec, fixedPageable);
|
||||
return noticesPage.map(portalNoticeMapper::map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PortalNoticeDTO getNotice(String id) {
|
||||
PortalNotice portalNotice = portalNoticeService.getNotice(id);
|
||||
return portalNoticeMapper.map(portalNotice);
|
||||
PortalNoticeDTO dto = portalNoticeMapper.map(portalNotice);
|
||||
populateIncident(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void populateIncident(PortalNoticeDTO dto) {
|
||||
if (!dto.isIncidentOrMaintenance()) {
|
||||
dto.setAffectedApis(Collections.emptyList());
|
||||
return;
|
||||
}
|
||||
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
|
||||
if (!incidentOpt.isPresent()) {
|
||||
dto.setAffectedApis(Collections.emptyList());
|
||||
return;
|
||||
}
|
||||
DjbApistatusIncident incident = incidentOpt.get();
|
||||
dto.setSummary(incident.getSummary());
|
||||
dto.setStartedAt(incident.getStartedAt());
|
||||
dto.setEndAt(incident.getEndAt());
|
||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
||||
|
||||
List<IncidentAffectedApiDTO> apis = incidentApiRepository
|
||||
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
||||
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
|
||||
.collect(Collectors.toList());
|
||||
dto.setAffectedApis(apis);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public class PartnershipApplicationController {
|
||||
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
||||
|
||||
// 성공 메시지 추가
|
||||
redirectAttributes.addFlashAttribute("success", "사업제휴신청이 완료되었습니다.");
|
||||
redirectAttributes.addFlashAttribute("success", "피드백/개선요청 등록이 완료되었습니다.");
|
||||
|
||||
httpServletRequest.getSession().removeAttribute("previousPage");
|
||||
return "redirect:/partnership";
|
||||
|
||||
@@ -5,14 +5,18 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -21,13 +25,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
private final PartnershipApplicationService partnershipApplicationService;
|
||||
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
||||
private final FileService fileService;
|
||||
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
||||
private final CommunityAdminNotifier portalAdminNotifier;
|
||||
|
||||
|
||||
@Override
|
||||
public void createPartnershipApplication(PartnershipApplicationDTO partnershipApplicationDTO) throws IOException {
|
||||
|
||||
FileInfo file = null;
|
||||
if (!partnershipApplicationDTO.getFiles().isEmpty()) {
|
||||
if (partnershipApplicationDTO.getFiles() != null && !partnershipApplicationDTO.getFiles().isEmpty()) {
|
||||
try {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
FileService.setInternalUserContext(UserTypeUtil.isInternalUser(user));
|
||||
@@ -43,5 +49,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
partnershipApplication.setFileId(file.getFileId());
|
||||
}
|
||||
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
|
||||
|
||||
// 개선요청/제휴 게시물 등록 시 portal-admin 에게 알림 (INQUIRY_CREATED 와 동일 패턴)
|
||||
PortalAuthenticatedUser writer = SecurityUtil.getPortalAuthenticatedUser();
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("partnershipId", partnershipApplication.getId());
|
||||
params.put("bizSubject", partnershipApplication.getBizSubject());
|
||||
if (writer != null) {
|
||||
params.put("writerName", writer.getUserName());
|
||||
}
|
||||
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public class InquiryDTO {
|
||||
@Size(max = 4000, message = "제목은 50자를 초과할 수 없습니다.")
|
||||
private String inquiryDetail;
|
||||
|
||||
@Pattern(regexp = "^(PENDING|IN_PROGRESS|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||
@Pattern(regexp = "^(PENDING|RESPONDED|CLOSED)$", message = "유효하지 않은 문의 상태입니다.")
|
||||
private String inquiryStatus;
|
||||
|
||||
private String inquirerName;
|
||||
|
||||
@@ -6,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,33 @@
|
||||
package com.eactive.apim.portal.apps.session.repository;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
|
||||
|
||||
List<UserSession> findByLoginId(String loginId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
|
||||
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
|
||||
int forceLogoutAllSessions(@Param("loginId") String loginId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
|
||||
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
|
||||
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.eactive.apim.portal.apps.session.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserSessionService {
|
||||
|
||||
private static final String PROPERTY_GROUP = "Portal";
|
||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
||||
|
||||
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
|
||||
private static final long CLEANUP_MARGIN_MINUTES = 1;
|
||||
|
||||
private final UserSessionRepository userSessionRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<UserSession> getActiveSession(String loginId) {
|
||||
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
|
||||
|
||||
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
|
||||
return sessions.stream()
|
||||
.filter(s -> "N".equals(s.getForceLogout()))
|
||||
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 세션 등록
|
||||
*/
|
||||
@Transactional
|
||||
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
|
||||
UserSession session = UserSession.builder()
|
||||
.sessionId(sessionId)
|
||||
.userId(userId)
|
||||
.loginId(loginId)
|
||||
.loginTime(LocalDateTime.now())
|
||||
.lastAccessTime(LocalDateTime.now())
|
||||
.ipAddress(ipAddress)
|
||||
.userAgent(truncate(userAgent, 500))
|
||||
.forceLogout("N")
|
||||
.build();
|
||||
userSessionRepository.save(session);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
|
||||
*/
|
||||
@Transactional
|
||||
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
|
||||
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
|
||||
if (count > 0) {
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
|
||||
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
|
||||
*/
|
||||
@Transactional
|
||||
public void forceLogoutAllSessions(String loginId) {
|
||||
int count = userSessionRepository.forceLogoutAllSessions(loginId);
|
||||
if (count > 0) {
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 세션의 강제 로그아웃 여부 확인
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isForceLoggedOut(String sessionId) {
|
||||
return userSessionRepository.findById(sessionId)
|
||||
.map(s -> "Y".equals(s.getForceLogout()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 삭제
|
||||
*/
|
||||
@Transactional
|
||||
public void removeSession(String sessionId) {
|
||||
if (userSessionRepository.existsById(sessionId)) {
|
||||
userSessionRepository.deleteById(sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 접근 시간 갱신 (세션 연장)
|
||||
*/
|
||||
@Transactional
|
||||
public void updateLastAccessTime(String sessionId) {
|
||||
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
|
||||
*/
|
||||
public int getSessionTimeoutMinutes() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
PROPERTY_GROUP,
|
||||
PROPERTY_NAME,
|
||||
DEFAULT_TIMEOUT_MINUTES,
|
||||
"세션 타임아웃 시간 (분)"
|
||||
);
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
|
||||
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long getRemainingSeconds(String sessionId) {
|
||||
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||
return userSessionRepository.findById(sessionId)
|
||||
.map(s -> {
|
||||
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
|
||||
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
|
||||
return Math.max(0, remaining);
|
||||
})
|
||||
.orElse(0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 세션 정리 (1분 주기).
|
||||
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
|
||||
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
|
||||
*/
|
||||
@Scheduled(fixedRate = 60000)
|
||||
@Transactional
|
||||
public void cleanupExpiredSessions() {
|
||||
int timeoutMinutes = getSessionTimeoutMinutes();
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
|
||||
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
|
||||
if (deleted > 0) {
|
||||
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,18 @@
|
||||
package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.dto.*;
|
||||
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -33,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@Secured("ROLE_ACCOUNT")
|
||||
@RequiredArgsConstructor
|
||||
@@ -53,6 +48,7 @@ public class AccountController {
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
@@ -105,6 +101,12 @@ public class AccountController {
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
|
||||
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
|
||||
userSessionService.removeSession(session.getId());
|
||||
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
@@ -142,8 +144,8 @@ public class AccountController {
|
||||
// ROLE_USER인 경우 초대 여부 확인
|
||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
java.util.Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
user.getEmailAddr(), InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
mav.addObject("hasPendingInvitation", true);
|
||||
@@ -262,7 +264,7 @@ public class AccountController {
|
||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
return "apps/mypage/orgTransfer";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -22,18 +23,22 @@ public class AuthController {
|
||||
private final AuthFacade authFacade;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final EmailValidator emailValidator;
|
||||
private final PortalUserService portalUserService;
|
||||
|
||||
|
||||
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
|
||||
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
|
||||
PortalUserService portalUserService) {
|
||||
this.authFacade = authFacade;
|
||||
this.cellPhoneValidator = cellPhoneValidator;
|
||||
this.emailValidator = emailValidator;
|
||||
this.portalUserService = portalUserService;
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/request_auth_number")
|
||||
public ValidationResponse requestAuthNumber(
|
||||
@RequestParam(required = false) String mobileNumber,
|
||||
@RequestParam(required = false) String purpose,
|
||||
HttpSession session) {
|
||||
|
||||
ValidationResponse response = new ValidationResponse();
|
||||
@@ -44,6 +49,16 @@ public class AuthController {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
|
||||
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
|
||||
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
|
||||
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(mobileNumber)) {
|
||||
response.setValid(false);
|
||||
response.setMessage("이미 가입된 휴대폰 번호입니다.");
|
||||
return response;
|
||||
}
|
||||
|
||||
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
|
||||
|
||||
// 이전 세션 데이터 정리
|
||||
|
||||
@@ -29,7 +29,8 @@ public class OrgRegisterController {
|
||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
return MAIN_ORG_REGISTER;
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ public class OrgRegisterController {
|
||||
|
||||
if (response.isValid()) {
|
||||
model.addAttribute("message", response.getMessage());
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
return MAIN_REGISTER_RESULT;
|
||||
} else {
|
||||
setModelForErrorOrg(model, orgDTO, response.getMessage());
|
||||
@@ -91,6 +93,7 @@ public class OrgRegisterController {
|
||||
model.addAttribute("portalOrg", orgDTO);
|
||||
model.addAttribute("error", errorMessage);
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
|
||||
import com.eactive.apim.portal.common.dto.ResponseDTO;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -18,12 +19,16 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class UserManRestController {
|
||||
|
||||
private final UserManFacade userManFacade;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
|
||||
// 1. Cancel invitation
|
||||
@PostMapping("/cancel-invitation")
|
||||
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
|
||||
boolean notifyConsent = user.isNotifyConsent();
|
||||
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
|
||||
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
|
||||
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||
}
|
||||
|
||||
// 2. Inactivate user
|
||||
@@ -40,25 +45,25 @@ public class UserManRestController {
|
||||
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
|
||||
}
|
||||
|
||||
//5. register new user
|
||||
//5. register new user (휴대폰 번호 기반 초대)
|
||||
@PostMapping("/invite")
|
||||
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
|
||||
String email = newUser.getEmailAddr();
|
||||
String mobile = newUser.getMobileNumber();
|
||||
|
||||
// 이메일 형식 검증
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
|
||||
// 휴대폰 번호 검증
|
||||
if (mobile == null || mobile.trim().isEmpty()) {
|
||||
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
|
||||
}
|
||||
if (!cellPhoneValidator.isValid(mobile, null)) {
|
||||
return new ResponseDTO(400, "ERROR", "올바른 휴대폰 번호 형식을 입력해주세요.");
|
||||
}
|
||||
|
||||
// 이메일 정규식 검증
|
||||
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
|
||||
if (!email.matches(emailPattern)) {
|
||||
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
|
||||
}
|
||||
boolean notifyConsent = newUser.isNotifyConsent();
|
||||
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
|
||||
|
||||
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
|
||||
|
||||
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
|
||||
return new ResponseDTO(200, "SUCCESS", notifyConsent
|
||||
? "초대 메시지가 발송되었습니다."
|
||||
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
|
||||
}
|
||||
|
||||
@PostMapping("/change-role")
|
||||
@@ -69,6 +74,20 @@ public class UserManRestController {
|
||||
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
|
||||
}
|
||||
|
||||
// 관리자 -> 이용자 변경 (본인 제외)
|
||||
@PostMapping("/revoke-manager")
|
||||
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
|
||||
}
|
||||
|
||||
// 소속 제외 -> 개인이용자로 전환
|
||||
@PostMapping("/remove-from-org")
|
||||
public ResponseDTO removeFromOrg(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.removeFromOrg(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "소속에서 제외되어 개인이용자로 변경되었습니다.");
|
||||
}
|
||||
|
||||
// 6. Resend invitation
|
||||
@PostMapping("/resend-invitation")
|
||||
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
|
||||
|
||||
@@ -4,11 +4,14 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
@@ -16,11 +19,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
@@ -50,6 +57,7 @@ public class UserRegisterController {
|
||||
|
||||
private final PortalUserService portalUserService;
|
||||
private final UserRegisterFacade userRegisterFacade;
|
||||
private final AuthFacade authFacade;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
@@ -57,6 +65,7 @@ public class UserRegisterController {
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
@GetMapping("/signup")
|
||||
public String showSignupSelection() {
|
||||
@@ -67,8 +76,6 @@ public class UserRegisterController {
|
||||
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
|
||||
boolean isInvited = false;
|
||||
String orgName = null;
|
||||
String email = null;
|
||||
String[] parts = null;
|
||||
|
||||
if (invitationToken != null) {
|
||||
// 8글자 토큰 사용
|
||||
@@ -80,29 +87,23 @@ public class UserRegisterController {
|
||||
String orgId = invitation.get().getOrgId();
|
||||
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
|
||||
orgName = org.map(PortalOrg::getOrgName).orElse(null);
|
||||
email = invitation.get().getInvitationEmail();
|
||||
parts = email.split("@");
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
|
||||
model.addAttribute("isInvited", isInvited);
|
||||
model.addAttribute("orgName", orgName);
|
||||
model.addAttribute("email", email);
|
||||
model.addAttribute("loginId", email);
|
||||
|
||||
if (parts != null) {
|
||||
model.addAttribute("emailId", parts[0]);
|
||||
model.addAttribute("domain", parts[1]);
|
||||
} else {
|
||||
model.addAttribute("emailId", "");
|
||||
model.addAttribute("domain", "");
|
||||
}
|
||||
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 는 가입자가 직접 입력한다
|
||||
model.addAttribute("email", "");
|
||||
model.addAttribute("loginId", "");
|
||||
model.addAttribute("emailId", "");
|
||||
model.addAttribute("domain", "");
|
||||
|
||||
model.addAttribute("authTtl", portalProperties.getAuthTtl());
|
||||
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
|
||||
return MAIN_USER_REGISTER;
|
||||
|
||||
@@ -138,8 +139,21 @@ public class UserRegisterController {
|
||||
}
|
||||
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
||||
session.removeAttribute("invitationToken");
|
||||
|
||||
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
|
||||
if (invitationToken == null) {
|
||||
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
|
||||
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
|
||||
return "redirect:/signup/verification-email";
|
||||
}
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return "redirect:/signup/complete";
|
||||
}
|
||||
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
|
||||
return "redirect:/signup/complete/corporate";
|
||||
} catch (Exception e) {
|
||||
setModelForError(session, model, agreement, portalUserRegistrationDTO,
|
||||
"처리 중 오류가 발생했습니다: " + e.getMessage());
|
||||
@@ -169,6 +183,57 @@ public class UserRegisterController {
|
||||
return MAIN_EMAIL_COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 직후 이메일 인증 페이지.
|
||||
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
|
||||
*/
|
||||
@GetMapping("/signup/verification-email")
|
||||
public String showSignupVerificationEmail(HttpSession session, Model model) {
|
||||
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||
if (email == null) {
|
||||
return "redirect:/login";
|
||||
}
|
||||
model.addAttribute("email", email);
|
||||
return "apps/register/signupVerificationEmail";
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
|
||||
*/
|
||||
@PostMapping("/signup/send-verification-code")
|
||||
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
|
||||
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||
if (email == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
|
||||
}
|
||||
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다.
|
||||
*/
|
||||
@PostMapping("/signup/verify-email-code")
|
||||
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
|
||||
HttpSession session) {
|
||||
String email = (String) session.getAttribute("signupVerificationEmail");
|
||||
if (email == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
|
||||
}
|
||||
|
||||
String code = request.get("code");
|
||||
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
|
||||
|
||||
if (response.isValid()) {
|
||||
PortalUser user = portalUserService.findByEmailAddr(email);
|
||||
portalUserService.activateUser(user.getId());
|
||||
session.removeAttribute("signupVerificationEmail");
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/signup/decision")
|
||||
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
|
||||
HttpSession session,
|
||||
@@ -181,6 +246,11 @@ public class UserRegisterController {
|
||||
String decodedToken = decodeInvitationToken(invitationToken);
|
||||
session.setAttribute("decisionToken", decodedToken);
|
||||
|
||||
// 이미 로그인된 상태면 수락 화면으로 직행
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/signup/decision_process";
|
||||
}
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
||||
|
||||
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
|
||||
@@ -188,9 +258,9 @@ public class UserRegisterController {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
// 이메일로 사용자 정보 조회
|
||||
String email = invitation.get().getInvitationEmail();
|
||||
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||
// 휴대폰 번호로 사용자 정보 조회
|
||||
String mobile = invitation.get().getInvitationMobile();
|
||||
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
|
||||
|
||||
if (!portalUser.isPresent()) {
|
||||
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
|
||||
@@ -219,13 +289,14 @@ public class UserRegisterController {
|
||||
public String showDecisionProcessPage(HttpSession session, Model model) {
|
||||
session.removeAttribute("decisionToken");
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (!invitation.isPresent()) {
|
||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||
return "redirect:/";
|
||||
}
|
||||
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
|
||||
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
|
||||
model.addAttribute("error", "유효하지 않은 초대입니다");
|
||||
return "redirect:/";
|
||||
}
|
||||
@@ -241,7 +312,9 @@ public class UserRegisterController {
|
||||
model.addAttribute("orgName", org.get().getOrgName());
|
||||
model.addAttribute("invitationCode", invitation.get().getToken());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
@@ -260,7 +333,7 @@ public class UserRegisterController {
|
||||
|
||||
agreementValidator.validate(agreement, bindingResult);
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
|
||||
|
||||
@@ -270,13 +343,20 @@ public class UserRegisterController {
|
||||
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
||||
model.addAttribute("orgName", org.get().getOrgName());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
} else {
|
||||
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
|
||||
|
||||
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
|
||||
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
}
|
||||
|
||||
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
|
||||
session.removeAttribute("pendingInvitation");
|
||||
session.removeAttribute("pendingInvitationToken");
|
||||
@@ -294,24 +374,6 @@ public class UserRegisterController {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
@GetMapping("/validate_email")
|
||||
public String validateUserEmail(@RequestParam(name = "token", required = false) String token) {
|
||||
String decodedToken = new String(Base64.decode(token));
|
||||
try {
|
||||
String decToken = encryptionUtil.decrypt(decodedToken);
|
||||
String[] tokens = decToken.split(":");
|
||||
boolean approvalRequired = portalUserService.activateUser(tokens[1]);
|
||||
if (approvalRequired) {
|
||||
return "apps/register/emailValidationResultCorpManager";
|
||||
} else {
|
||||
return "apps/register/emailValidationResultUser";
|
||||
}
|
||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException |
|
||||
BadPaddingException e) {
|
||||
throw new IllegalArgumentException("인증 정보 오류. 관리자 문의하세요.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 에러 발생 시 모델 세팅을 위한 private 메서드
|
||||
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
|
||||
@@ -325,7 +387,8 @@ public class UserRegisterController {
|
||||
model.addAttribute("agreement", agreement);
|
||||
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("msgType", "sms");
|
||||
}
|
||||
|
||||
@@ -437,10 +500,10 @@ public class UserRegisterController {
|
||||
session.removeAttribute("invitationCodeAttempts");
|
||||
session.removeAttribute("invitationCodeLockoutUntil");
|
||||
|
||||
// 기존 사용자 여부 확인
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
|
||||
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
|
||||
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
if (existingUser) {
|
||||
// 기존 사용자: 초대 수락 페이지로 이동
|
||||
return "redirect:/signup/decision?invitation=" + normalizedCode;
|
||||
} else {
|
||||
|
||||
@@ -23,6 +23,9 @@ public class PortalUserDTO implements Serializable {
|
||||
|
||||
private String emailAddr;
|
||||
|
||||
// 초대 시 알림 수신 동의 여부 (false 면 초대 레코드만 생성, 메시지 미발송)
|
||||
private boolean notifyConsent;
|
||||
|
||||
private PortalOrgDTO portalOrg;
|
||||
|
||||
private UserStatus userStatus;
|
||||
|
||||
@@ -192,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
// 사용자 생성 및 기관 연결
|
||||
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(newUser);
|
||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
@@ -240,7 +240,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
|
||||
portalUserRepository.save(existingUser);
|
||||
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(existingUser);
|
||||
|
||||
@@ -274,7 +274,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
portalUserRepository.save(existingUser);
|
||||
|
||||
// 새로운 법인용 개인정보수집동의서 저장
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(existingUser);
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.DurationParser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.event.UserInvitationCancelEvent;
|
||||
@@ -18,6 +20,8 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
@@ -52,6 +56,9 @@ public class UserManFacade {
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final UserLogRepository userLogRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||
return portalUserRepository
|
||||
@@ -94,23 +101,35 @@ public class UserManFacade {
|
||||
dto.setMobileNumber(null);
|
||||
}
|
||||
|
||||
public void sendInvitation(PortalUser sender, String emailAddr) {
|
||||
// 이메일 형식 검증
|
||||
if (emailAddr == null || emailAddr.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
|
||||
/**
|
||||
* 법인 개발자 초대 (휴대폰 번호 기반).
|
||||
*
|
||||
* @param sender 초대를 보내는 법인 관리자
|
||||
* @param mobileRaw 초대 대상 휴대폰 번호 (하이픈 유무 무관)
|
||||
* @param notifyConsent 알림 수신 동의 여부. false 면 초대 레코드만 생성하고 메시지 발송은 스킵한다.
|
||||
*/
|
||||
public void sendInvitation(PortalUser sender, String mobileRaw, boolean notifyConsent) {
|
||||
// 휴대폰 번호 형식 검증
|
||||
if (mobileRaw == null || mobileRaw.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("휴대폰 번호를 입력해주세요.");
|
||||
}
|
||||
if (!cellPhoneValidator.isValid(mobileRaw, null)) {
|
||||
throw new IllegalArgumentException("올바른 휴대폰 번호 형식을 입력해주세요.");
|
||||
}
|
||||
|
||||
// 이메일 정규식 검증
|
||||
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
|
||||
if (!emailAddr.matches(emailPattern)) {
|
||||
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
|
||||
}
|
||||
// 저장 포맷(하이픈 포함, 예: 010-1234-5678)으로 정규화 — PortalUser.mobileNumber 저장 포맷과
|
||||
// 일치시켜야 결정적 암호화 equality 조회가 매칭된다.
|
||||
String mobile = normalizeMobile(mobileRaw);
|
||||
|
||||
// 중복 초대 확인
|
||||
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
|
||||
checkDuplicateInvitation(sender.getPortalOrg().getId(), mobile);
|
||||
|
||||
//기존 사용자인지 확인
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
|
||||
// 기존 사용자인지 휴대폰 번호로 확인 (mobileNumber 는 유니크가 아니므로 다건 가능)
|
||||
List<PortalUser> matched = portalUserRepository.findAllByMobileNumber(mobile);
|
||||
if (matched.size() > 1) {
|
||||
throw new IllegalArgumentException("해당 휴대폰 번호로 가입된 계정이 여러 건 존재하여 초대할 수 없습니다. 관리자에게 문의해주세요.");
|
||||
}
|
||||
Optional<PortalUser> existingUser = matched.isEmpty() ? Optional.empty() : Optional.of(matched.get(0));
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
@@ -119,7 +138,12 @@ public class UserManFacade {
|
||||
}
|
||||
}
|
||||
|
||||
String token = createInvitation(sender, emailAddr);
|
||||
String token = createInvitation(sender, mobile);
|
||||
|
||||
// 알림 수신 미동의 시 초대 레코드만 생성하고 메시지 발송은 스킵
|
||||
if (!notifyConsent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
|
||||
MessageRecipient recipient;
|
||||
@@ -129,7 +153,7 @@ public class UserManFacade {
|
||||
params.put("corpName", sender.getPortalOrg().getOrgName());
|
||||
params.put("authNumber", token); // 8-character invitation code
|
||||
|
||||
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
|
||||
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 휴대폰 번호 사용
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
params.put("loginId", user.getUserName());
|
||||
@@ -137,26 +161,36 @@ public class UserManFacade {
|
||||
params.put("url", "signup/decision?invitation=" + token);
|
||||
|
||||
recipient = MessageRecipient.of(user);
|
||||
// MessageRecipient.of() 는 유선전화(phoneNumber)를 채우므로 휴대폰 번호로 보정
|
||||
recipient.setPhone(user.getMobileNumber());
|
||||
} else {
|
||||
params.put("loginId", emailAddr);
|
||||
params.put("NAME", emailAddr);
|
||||
params.put("loginId", mobile);
|
||||
params.put("NAME", mobile);
|
||||
params.put("url", "signup/portalUser?invitation=" + token);
|
||||
|
||||
String emailUsername = emailAddr.contains("@") ? emailAddr.substring(0, emailAddr.indexOf("@")) : emailAddr;
|
||||
recipient = MessageRecipient.builder()
|
||||
.userId(emailAddr)
|
||||
.username(emailUsername)
|
||||
.userId(mobile)
|
||||
.username(mobile)
|
||||
.phone(mobile)
|
||||
.build();
|
||||
}
|
||||
|
||||
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
public String createInvitation(PortalUser sender, String emailAddr) {
|
||||
/**
|
||||
* 휴대폰 번호를 저장 포맷(하이픈 포함)으로 정규화한다.
|
||||
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
|
||||
*/
|
||||
private String normalizeMobile(String raw) {
|
||||
return PhoneNumberUtil.normalize(raw);
|
||||
}
|
||||
|
||||
public String createInvitation(PortalUser sender, String mobile) {
|
||||
UserInvitation newInvitation = new UserInvitation();
|
||||
newInvitation.setAdminId(sender.getId());
|
||||
newInvitation.setOrgId(sender.getPortalOrg().getId());
|
||||
newInvitation.setInvitationEmail(emailAddr);
|
||||
newInvitation.setInvitationMobile(mobile);
|
||||
|
||||
// Set additional required fields
|
||||
newInvitation.setInvitationDate(LocalDateTime.now());
|
||||
@@ -215,6 +249,10 @@ public class UserManFacade {
|
||||
portalUserRepository.save(targetUser);
|
||||
portalUserRepository.save(currentUser);
|
||||
|
||||
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUserId(targetUser.getEmailAddr());
|
||||
recipient.setPhone(targetUser.getMobileNumber());
|
||||
@@ -225,7 +263,57 @@ public class UserManFacade {
|
||||
|
||||
}
|
||||
|
||||
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId) {
|
||||
/**
|
||||
* 법인 관리자 권한 회수 (관리자 → 이용자).
|
||||
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
|
||||
*/
|
||||
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
||||
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
||||
|
||||
if (targetUser.getPortalOrg() == null
|
||||
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
|
||||
throw new IllegalArgumentException("잘 못 된 접근입니다");
|
||||
}
|
||||
if (targetUser.getId().equals(admin.getId())) {
|
||||
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
|
||||
}
|
||||
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
|
||||
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 소속 제외 (기관에서 제외 → 개인이용자).
|
||||
* 대상은 본인이 아닌 같은 기관의 사용자여야 한다. portalOrg 를 비우고 ROLE_USER 로 전환한다.
|
||||
*/
|
||||
public void removeFromOrg(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
||||
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
||||
|
||||
if (targetUser.getPortalOrg() == null
|
||||
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
|
||||
throw new IllegalArgumentException("잘 못 된 접근입니다");
|
||||
}
|
||||
if (targetUser.getId().equals(admin.getId())) {
|
||||
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
|
||||
}
|
||||
|
||||
targetUser.setPortalOrg(null);
|
||||
targetUser.setRoleCode(RoleCode.ROLE_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
|
||||
UserInvitation invitation = userInvitationRepository.findById(invitationId)
|
||||
.orElseThrow(() -> new NotFoundException("Invitation not found with id: " + invitationId));
|
||||
|
||||
@@ -237,17 +325,19 @@ public class UserManFacade {
|
||||
throw new IllegalStateException("Invitation is not in a cancelable state");
|
||||
}
|
||||
|
||||
// invitationId 로 사용자 이름 조회, 실패시 id 를 이름으로 대체
|
||||
String name = portalUserRepository.findById(invitation.getInvitationEmail())
|
||||
.map(PortalUser::getUserName)
|
||||
.orElse(invitation.getInvitationEmail());
|
||||
invitation.setStatus(InvitationStatus.CANCELED);
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
// name 이 이메일 일 경우 @ 앞부분 만 사용 (안하면 암호화 해야함...)
|
||||
if (name.contains("@")) {
|
||||
name = name.substring(0, name.indexOf("@"));
|
||||
// 알림 수신 미동의 시 취소 알림 메시지 발송은 스킵
|
||||
if (!notifyConsent) {
|
||||
return;
|
||||
}
|
||||
|
||||
invitation.setStatus(InvitationStatus.CANCELED);
|
||||
// invitationMobile 로 사용자 이름 조회, 실패시 마스킹된 번호로 대체
|
||||
String name = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
|
||||
.stream().findFirst()
|
||||
.map(PortalUser::getUserName)
|
||||
.orElse(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
|
||||
|
||||
messageHandlerService.publishEvent(UserInvitationCancelEvent.KEY,
|
||||
MessageRecipient.builder()
|
||||
@@ -260,8 +350,6 @@ public class UserManFacade {
|
||||
}}
|
||||
);
|
||||
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
}
|
||||
|
||||
public void inactivateUser(PortalAuthenticatedUser admin, String userId) {
|
||||
@@ -298,8 +386,8 @@ public class UserManFacade {
|
||||
.stream()
|
||||
.map(invitation -> {
|
||||
PortalUserDTO dto = portalUserMapper.pendingUserVO(invitation);
|
||||
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(invitation.getInvitationEmail()));
|
||||
dto.setEmailAddr(null); // Clear unmasked email
|
||||
dto.setMaskedMobileNumber(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
|
||||
dto.setMobileNumber(null); // Clear unmasked mobile
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
@@ -328,32 +416,32 @@ public class UserManFacade {
|
||||
existingInvitation.setStatus(InvitationStatus.CANCELED);
|
||||
userInvitationRepository.save(existingInvitation);
|
||||
|
||||
// 새로운 초대 생성 및 발송
|
||||
sendInvitation(sender, existingInvitation.getInvitationEmail());
|
||||
// 새로운 초대 생성 및 발송 (재발송 시 최초 동의값을 알 수 없으므로 발송함을 기본으로)
|
||||
sendInvitation(sender, existingInvitation.getInvitationMobile(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중복 초대 확인
|
||||
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
|
||||
* 같은 기관에서 같은 휴대폰 번호로 이미 PENDING 상태인 초대가 있는지 확인
|
||||
*/
|
||||
private void checkDuplicateInvitation(String orgId, String emailAddr) {
|
||||
private void checkDuplicateInvitation(String orgId, String mobile) {
|
||||
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
|
||||
emailAddr, orgId, InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndOrgIdAndStatus(
|
||||
mobile, orgId, InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
|
||||
throw new IllegalArgumentException("이미 초대가 진행 중인 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
|
||||
Optional<UserInvitation> otherOrgPendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
emailAddr, InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
mobile, InvitationStatus.PENDING);
|
||||
|
||||
if (otherOrgPendingInvitation.isPresent()
|
||||
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
|
||||
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
|
||||
throw new IllegalArgumentException("해당 휴대폰 번호는 다른 기관에서 초대 진행 중입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,12 +137,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
|
||||
}
|
||||
|
||||
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
|
||||
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
//
|
||||
// if(existingUser != null) {
|
||||
// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
// }
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
|
||||
@@ -150,7 +149,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_IND);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendEmailActivation(newUser);
|
||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||
@@ -173,6 +172,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false,"입력값을 확인해 주세요.");
|
||||
}
|
||||
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
|
||||
if(existingUser != null) {
|
||||
@@ -190,7 +195,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
}
|
||||
|
||||
agreementsFacade.deleteUserAgreements(newUser.getId()); //이전에 동의한 내역은 삭제
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
invitation.setStatus(UserInvitationEnums.InvitationStatus.COMPLETED);
|
||||
invitation.setCompleteDate(LocalDateTime.now());
|
||||
@@ -242,7 +247,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
try {
|
||||
if ("accept".equals(action)) {
|
||||
// 수락 처리
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail())
|
||||
PortalUser user = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
|
||||
.stream().findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다."));
|
||||
|
||||
// 서비스 계층에 위임
|
||||
@@ -253,11 +259,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
userInvitationRepository.save(invitation);
|
||||
|
||||
agreementsFacade.deleteUserAgreements(user.getId());
|
||||
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT_ORG);
|
||||
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
ValidationResponse response = new ValidationResponse();
|
||||
response.setValid(true);
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
|
||||
response.setShowPopup(true);
|
||||
response.setPopupType("success");
|
||||
return response;
|
||||
|
||||
@@ -15,7 +15,7 @@ public abstract class PortalUserMapper implements GenericMapper<PortalUserDTO, P
|
||||
|
||||
public abstract PortalAuthenticatedUser portalUserToAuthenticatedUser(PortalUser portalUser);
|
||||
|
||||
@Mapping(target = "emailAddr", source = "invitationEmail")
|
||||
@Mapping(target = "mobileNumber", source = "invitationMobile")
|
||||
public abstract PortalUserDTO pendingUserVO(UserInvitation invitation);
|
||||
|
||||
public PortalUserDTO toDTO(PortalUser portalUser) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -30,7 +31,9 @@ import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
@@ -58,21 +61,46 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
|
||||
String normalizedUsername = username != null ? username.toLowerCase() : null;
|
||||
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
|
||||
return buildAuthenticatedUser(portalUser);
|
||||
} catch (UserNotFoundException e) {
|
||||
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층)을 채운 PortalAuthenticatedUser 를 생성한다.
|
||||
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
|
||||
*/
|
||||
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext 의 Authentication 을 교체한다.
|
||||
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 등)을 즉시 반영한다.
|
||||
*/
|
||||
public void reloadCurrentAuthentication() {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
|
||||
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
|
||||
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
|
||||
|
||||
UsernamePasswordAuthenticationToken newAuth =
|
||||
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
|
||||
newAuth.setDetails(refreshed);
|
||||
SecurityContextHolder.getContext().setAuthentication(newAuth);
|
||||
}
|
||||
|
||||
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
|
||||
try {
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
@@ -122,37 +150,6 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
messageHandlerService.publishEvent(UserPasswordResetEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
public void resendVerificationEmail(String loginId) {
|
||||
|
||||
PortalUser user = portalUserRepository.findByLoginId(loginId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
||||
|
||||
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ACTIVE)) {
|
||||
throw new IllegalArgumentException("이미 인증이 완료된 계정입니다.");
|
||||
}
|
||||
|
||||
// Enum을 직접 전달
|
||||
List<MessageRequest> messageRequests = messageRequestRepository.findByEmailAndMessageCode(
|
||||
user.getLoginId(), MessageCode.USER_VERIFICATION_EMAIL
|
||||
);
|
||||
|
||||
if (!messageRequests.isEmpty()) {
|
||||
messageRequestRepository.deleteAll(messageRequests);
|
||||
}
|
||||
|
||||
HashMap<String, Object> params = new HashMap<>();
|
||||
String tokenValue = user.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + user.getId();
|
||||
|
||||
try {
|
||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
||||
MessageRecipient recipient = createMessageRecipient(user);
|
||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new SystemException("암호화 모듈 오류");
|
||||
}
|
||||
}
|
||||
|
||||
private MessageRecipient createMessageRecipient(PortalUser user) {
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUsername(user.getUserName());
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -24,6 +25,12 @@ import org.springframework.util.StringUtils;
|
||||
@Service
|
||||
@Transactional
|
||||
public class PortalUserService {
|
||||
|
||||
/** PortalProperty 그룹명 */
|
||||
private static final String PORTAL_PROPERTY_GROUP = "Portal";
|
||||
/** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */
|
||||
private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow";
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -68,6 +75,33 @@ public class PortalUserService {
|
||||
return portalUserRepository.existsByLoginId(normalizedLoginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 번호 중복 여부 확인.
|
||||
* mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다.
|
||||
* (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다)
|
||||
*/
|
||||
public boolean existsByMobileNumber(String mobileNumber) {
|
||||
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 시 휴대폰 번호 중복 차단 활성 여부.
|
||||
* 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다.
|
||||
* - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성
|
||||
* - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isMobileDuplicateCheckEnabled() {
|
||||
String allow = portalPropertyService
|
||||
.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP)
|
||||
.getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false");
|
||||
boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow);
|
||||
return !allowed;
|
||||
}
|
||||
|
||||
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
|
||||
return portalUserRepository.countByPortalOrg(portalOrg) > 1;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
|
||||
@ControllerAdvice
|
||||
public class GlobalControllerAdvice {
|
||||
@@ -21,6 +22,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private PortalProperties portalProperties;
|
||||
|
||||
@Autowired
|
||||
private UserSessionService userSessionService;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -58,4 +62,12 @@ public class GlobalControllerAdvice {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
||||
*/
|
||||
@ModelAttribute("sessionTimeoutMinutes")
|
||||
public int sessionTimeoutMinutes() {
|
||||
return userSessionService.getSessionTimeoutMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
import org.springframework.boot.web.servlet.error.ErrorController;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -9,6 +11,8 @@ import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import javax.servlet.RequestDispatcher;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
@@ -16,6 +20,11 @@ import javax.servlet.http.HttpServletRequest;
|
||||
@Controller
|
||||
public class PortalErrorController implements ErrorController {
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public PortalErrorController(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/error", method = {RequestMethod.POST, RequestMethod.GET})
|
||||
public ModelAndView handleError(HttpServletRequest request) {
|
||||
@@ -27,10 +36,134 @@ public class PortalErrorController implements ErrorController {
|
||||
}
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("status", status);
|
||||
|
||||
if (isProd()) {
|
||||
// prod 환경에서는 내부 오류 정보를 노출하지 않고 이전과 동일한 일반 안내 메시지만 표시
|
||||
modelAndView.addObject("errorTitle", "일시적인 장애로 서비스가 중단되었습니다.");
|
||||
modelAndView.addObject("errorDescription", "서비스 이용에 불편을 드려 죄송합니다. 빠른 서비스를 제공할 수 있게 준비하겠습니다.");
|
||||
} else {
|
||||
modelAndView.addObject("errorTitle", resolveTitle(status));
|
||||
modelAndView.addObject("errorDescription", resolveDescription(status));
|
||||
modelAndView.addObject("errorMessage", resolveErrorMessage(request, status));
|
||||
modelAndView.addObject("activeProfile", resolveActiveProfile());
|
||||
// include-stacktrace=always(로컬 디버깅 프로파일)일 때만 화면에 스택 트레이스까지 노출
|
||||
if (isErrorDetailEnabled()) {
|
||||
Throwable throwable = resolveThrowable(request);
|
||||
if (throwable != null) {
|
||||
modelAndView.addObject("errorException", throwable.getClass().getName());
|
||||
modelAndView.addObject("errorStackTrace", stackTraceAsString(throwable));
|
||||
}
|
||||
}
|
||||
}
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/");
|
||||
}
|
||||
|
||||
private boolean isProd() {
|
||||
return environment.acceptsProfiles(Profiles.of("prod"));
|
||||
}
|
||||
|
||||
/**
|
||||
* server.error.include-stacktrace=always 인 경우에만 화면에 스택 트레이스를 노출한다.
|
||||
* (로컬 디버깅 프로파일에서만 해당 값을 always 로 설정 → 운영/스테이징에는 노출되지 않음)
|
||||
*/
|
||||
private boolean isErrorDetailEnabled() {
|
||||
String includeStacktrace = environment.getProperty("server.error.include-stacktrace", "never");
|
||||
return "always".equalsIgnoreCase(includeStacktrace);
|
||||
}
|
||||
|
||||
private String resolveActiveProfile() {
|
||||
String[] activeProfiles = environment.getActiveProfiles();
|
||||
if (activeProfiles.length == 0) {
|
||||
return "default";
|
||||
}
|
||||
return String.join(", ", activeProfiles);
|
||||
}
|
||||
|
||||
private Throwable resolveThrowable(HttpServletRequest request) {
|
||||
Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
|
||||
if (exception instanceof Throwable) {
|
||||
return (Throwable) exception;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String stackTraceAsString(Throwable throwable) {
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
throwable.printStackTrace(new PrintWriter(stringWriter));
|
||||
return stringWriter.toString();
|
||||
}
|
||||
|
||||
private String resolveTitle(Object status) {
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
if (httpStatus == null) {
|
||||
return "서비스 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
return "접근이 거부되었습니다.";
|
||||
}
|
||||
if (httpStatus == HttpStatus.NOT_FOUND) {
|
||||
return "요청하신 페이지를 찾을 수 없습니다.";
|
||||
}
|
||||
if (httpStatus.is4xxClientError()) {
|
||||
return "요청을 처리할 수 없습니다.";
|
||||
}
|
||||
if (httpStatus.is5xxServerError()) {
|
||||
return "서비스 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
return httpStatus.getReasonPhrase();
|
||||
}
|
||||
|
||||
private String resolveDescription(Object status) {
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
return "요청이 보안 검증을 통과하지 못했습니다.";
|
||||
}
|
||||
if (httpStatus == HttpStatus.NOT_FOUND) {
|
||||
return "주소가 변경되었거나 삭제되었을 수 있습니다.";
|
||||
}
|
||||
if (httpStatus != null && httpStatus.is5xxServerError()) {
|
||||
return "잠시 후 다시 시도해 주세요.";
|
||||
}
|
||||
return "요청 내용을 확인한 뒤 다시 시도해 주세요.";
|
||||
}
|
||||
|
||||
private String resolveErrorMessage(HttpServletRequest request, Object status) {
|
||||
Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
|
||||
HttpStatus httpStatus = resolveHttpStatus(status);
|
||||
StringBuilder message = new StringBuilder();
|
||||
|
||||
message.append("HTTP ").append(status);
|
||||
if (httpStatus != null) {
|
||||
message.append(" ").append(httpStatus.getReasonPhrase());
|
||||
}
|
||||
|
||||
if (requestUri != null) {
|
||||
message.append(" / 요청 경로: ").append(requestUri);
|
||||
}
|
||||
|
||||
Object exceptionMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
|
||||
if (exceptionMessage != null && !exceptionMessage.toString().trim().isEmpty()) {
|
||||
message.append(" / 원인: ").append(exceptionMessage);
|
||||
} else if (httpStatus == HttpStatus.FORBIDDEN) {
|
||||
message.append(" / 원인: 접근 권한 또는 CSRF 토큰을 확인하세요.");
|
||||
}
|
||||
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
private HttpStatus resolveHttpStatus(Object status) {
|
||||
if (status instanceof Integer) {
|
||||
return HttpStatus.resolve((Integer) status);
|
||||
}
|
||||
try {
|
||||
return HttpStatus.resolve(Integer.parseInt(status.toString()));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
||||
*
|
||||
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
|
||||
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
|
||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
||||
*
|
||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* # 미리보기(변경 안 함)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
||||
* # 실제 실행 (PII 컬럼)
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
||||
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
|
||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
|
||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
|
||||
);
|
||||
|
||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
||||
private static final List<String> AUDIT_TABLES = Arrays.asList(
|
||||
"PTL_USER", "ptl_faq", "ptl_org",
|
||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
|
||||
);
|
||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
||||
|
||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
|
||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
||||
}
|
||||
|
||||
@PostMapping("/encrypt-legacy")
|
||||
@Transactional("transactionManager")
|
||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertLocalOnly(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
if (includeAudit) {
|
||||
for (String table : AUDIT_TABLES) {
|
||||
targets.add(new TargetTable(table, AUDIT_COLUMNS));
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int totalChanged = 0;
|
||||
for (TargetTable target : targets) {
|
||||
for (String column : target.columns) {
|
||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
||||
results.add(r);
|
||||
totalChanged += (int) r.get("changed");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
||||
response.put("includeAudit", includeAudit);
|
||||
response.put("totalChanged", totalChanged);
|
||||
response.put("results", results);
|
||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
|
||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
||||
*/
|
||||
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("table", table);
|
||||
r.put("column", column);
|
||||
|
||||
List<String> values;
|
||||
try {
|
||||
values = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
|
||||
String.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
||||
r.put("distinct", 0);
|
||||
r.put("changed", 0);
|
||||
r.put("error", e.getMessage());
|
||||
return r;
|
||||
}
|
||||
|
||||
int changed = 0;
|
||||
for (String value : values) {
|
||||
String normalized;
|
||||
try {
|
||||
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
|
||||
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
|
||||
} catch (Exception e) {
|
||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
||||
continue;
|
||||
}
|
||||
if (normalized != null && !normalized.equals(value)) {
|
||||
if (!dryRun) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
|
||||
normalized, value);
|
||||
}
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
r.put("distinct", values.size());
|
||||
r.put("changed", changed);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
|
||||
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
|
||||
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
|
||||
*/
|
||||
private void assertNotBypass() {
|
||||
if (converter.isBypassMode()) {
|
||||
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
|
||||
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
||||
*/
|
||||
private void assertLocalOnly(HttpServletRequest request) {
|
||||
String remote = request.getRemoteAddr();
|
||||
boolean localAddr = "127.0.0.1".equals(remote)
|
||||
|| "0:0:0:0:0:0:0:1".equals(remote)
|
||||
|| "::1".equals(remote);
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TargetTable {
|
||||
final String table;
|
||||
final List<String> columns;
|
||||
|
||||
TargetTable(String table, List<String> columns) {
|
||||
this.table = table;
|
||||
this.columns = columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
package com.eactive.apim.portal.common.util;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 문자열 마스킹 처리를 위한 유틸리티 클래스
|
||||
*
|
||||
* <p>보안아키텍처 표준 마스킹 규칙(elink-online-core {@code MaskingUtils})과 동일한 알고리즘을
|
||||
* 구현한다. eapim-portal은 elink-online-core에 의존하지 않으므로 로직을 직접 포함한다.
|
||||
* 소유자 본인이 자신의 정보를 조회하는 경우 마스킹하지 않는 owner 인지 동작은 유지한다.</p>
|
||||
*
|
||||
* <pre>
|
||||
* 이름 2자: 뒤 1자리(가나→가*) / 3자 이상: 앞뒤 제외 가운데 전체(가나다라→가**라)
|
||||
* 이메일 아이디 앞2·뒤2 마스킹, 4자 이하 전체(test→****, test123→**st1**)
|
||||
* 전화 2·3번째 세그먼트 각각 뒤 2자리(010-1234-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
@@ -49,6 +51,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -93,9 +96,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
||||
user.getLoginId(), InvitationStatus.PENDING);
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
@@ -112,6 +116,12 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
@@ -10,10 +11,12 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.session.SessionRegistry;
|
||||
import org.springframework.security.core.session.SessionRegistryImpl;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
|
||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
|
||||
@@ -33,15 +36,30 @@ public class PortalConfigSecurity {
|
||||
|
||||
private final PortalLogoutSuccessHandler logoutSuccessHandler;
|
||||
|
||||
private final SessionValidationFilter sessionValidationFilter;
|
||||
|
||||
@Autowired
|
||||
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
|
||||
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
|
||||
PortalAuthenticationManager portalAuthenticationManager,
|
||||
PortalLogoutSuccessHandler logoutSuccessHandler) {
|
||||
PortalLogoutSuccessHandler logoutSuccessHandler,
|
||||
SessionValidationFilter sessionValidationFilter) {
|
||||
this.authenticationFailureHandler = authenticationFailureHandler;
|
||||
this.authenticationSuccessHandler = authenticationSuccessHandler;
|
||||
this.portalAuthenticationManager = portalAuthenticationManager;
|
||||
this.logoutSuccessHandler = logoutSuccessHandler;
|
||||
this.sessionValidationFilter = sessionValidationFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link SessionValidationFilter}가 Spring Security 필터 체인에서만 동작하도록
|
||||
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
|
||||
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
registration.setEnabled(false);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -64,6 +82,13 @@ public class PortalConfigSecurity {
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) {
|
||||
try {
|
||||
// CSRF 토큰을 쿠키(XSRF-TOKEN) 대신 HTTP 세션에 저장한다.
|
||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||
|
||||
http
|
||||
.authenticationManager(portalAuthenticationManager)
|
||||
.formLogin(form -> form
|
||||
@@ -75,21 +100,28 @@ public class PortalConfigSecurity {
|
||||
.successHandler(authenticationSuccessHandler))
|
||||
.logout(logout -> logout
|
||||
.logoutRequestMatcher(new AntPathRequestMatcher("/actionLogout.do", "GET"))
|
||||
// LogoutHandler 는 기본 SecurityContextLogoutHandler(세션 무효화)보다 먼저 실행된다.
|
||||
// 세션이 살아있는 이 시점에 DB 세션 레코드를 정리해야 중복세션 오탐("이미 접속중")을 막는다.
|
||||
.addLogoutHandler(logoutSuccessHandler)
|
||||
.logoutSuccessHandler(logoutSuccessHandler))
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
|
||||
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true)로 보낸다.
|
||||
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
|
||||
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(1) // Allow only one session per user
|
||||
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
|
||||
.expiredUrl("/login?expired") // Redirect when session expires
|
||||
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
|
||||
.and()
|
||||
.sessionFixation()
|
||||
.changeSessionId()
|
||||
);
|
||||
)
|
||||
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
} catch (Exception e) {
|
||||
@@ -97,9 +129,21 @@ public class PortalConfigSecurity {
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SessionRegistry sessionRegistry() {
|
||||
return new SessionRegistryImpl();
|
||||
/**
|
||||
* CSRF 토큰 누락/불일치는 "권한없음"이 아니라 (대개) 세션 만료다.
|
||||
* 로그인 페이지에 오래 머물러 세션 내 CSRF 토큰이 사라진 경우가 대표적이므로
|
||||
* 기존 세션 만료 안내(login.html의 {@code param.expired})를 재사용해 오해를 막는다.
|
||||
* 그 외 진짜 권한 위반(@Secured 등)은 기본 핸들러에 위임해 403을 유지한다.
|
||||
*/
|
||||
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
|
||||
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
|
||||
return (request, response, accessDeniedException) -> {
|
||||
if (accessDeniedException instanceof CsrfException) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?expired=true");
|
||||
return;
|
||||
}
|
||||
delegate.handle(request, response, accessDeniedException);
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.converter.EnabledStatusConverter;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
|
||||
import org.springframework.format.FormatterRegistry;
|
||||
@@ -18,10 +21,13 @@ import org.springframework.web.filter.CharacterEncodingFilter;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.multipart.support.MultipartFilter;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
|
||||
import org.springframework.web.servlet.resource.VersionResourceResolver;
|
||||
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
|
||||
|
||||
|
||||
@@ -32,6 +38,22 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
public static final String ERROR = "error";
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||
@Value("${app.resource-versioning.enabled:true}")
|
||||
private boolean resourceVersioningEnabled;
|
||||
|
||||
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
|
||||
// 개발환경에서 OFF 면 sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
@Value("${app.resource-caching.enabled:false}")
|
||||
private boolean resourceCachingEnabled;
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
@@ -61,16 +83,64 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
|
||||
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
|
||||
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
|
||||
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
|
||||
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
|
||||
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
|
||||
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
|
||||
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
|
||||
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
|
||||
}
|
||||
|
||||
registry.addResourceHandler("/css/**").addResourceLocations("/css/", "classpath:/static/css/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/webfonts/**").addResourceLocations("/webfonts/", "classpath:/static/webfonts/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/font/**").addResourceLocations("/font/", "classpath:/static/font/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/html/**").addResourceLocations("/html/", "classpath:/static/html/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
|
||||
/**
|
||||
* 정적자원 핸들러 공통 등록.
|
||||
*
|
||||
* <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 를 체인 앞단에 추가하여
|
||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||
*/
|
||||
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
||||
CacheControl cacheControl = isResourceCachingEnabled()
|
||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
||||
: CacheControl.noStore();
|
||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||
.addResourceLocations(locations)
|
||||
.setCacheControl(cacheControl)
|
||||
.resourceChain(isResourceCachingEnabled());
|
||||
if (isResourceVersioningEnabled()) {
|
||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||
}
|
||||
chain.addResolver(new PathResourceResolver());
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
|
||||
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
|
||||
*/
|
||||
private boolean isResourceVersioningEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceVersioningEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 서버측 캐싱(ResourceChain 의 CachingResourceResolver) 활성 여부.
|
||||
* prod 프로파일은 토글과 무관하게 항상 ON, 그 외 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
|
||||
*
|
||||
* <p>OFF 면 매 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
* ({@code spring.web.resources.cache} 는 브라우저 캐시 헤더만 제어할 뿐 이 서버측 캐시와는 무관하다.)</p>
|
||||
*/
|
||||
private boolean isResourceCachingEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceCachingEnabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,4 +193,13 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 로 치환하기 위한 필터.
|
||||
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 빈 등록한다.
|
||||
*/
|
||||
@Bean
|
||||
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
|
||||
return new ResourceUrlEncodingFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.logout.LogoutHandler;
|
||||
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -18,20 +20,37 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* 로그아웃 성공 시 세션 정보를 로깅하는 핸들러
|
||||
* 로그아웃 처리 핸들러.
|
||||
*
|
||||
* <p>DB 세션 레코드 정리와 로깅은 {@link LogoutHandler#logout}에서 수행한다. 이 시점은 기본
|
||||
* {@code SecurityContextLogoutHandler}가 HTTP 세션을 무효화하기 <b>전</b>이라 세션이 유효하다.
|
||||
* (성공 핸들러 {@link #onLogoutSuccess}는 무효화 <b>후</b>에 실행되어 {@code getSession(false)}가
|
||||
* null 이 되므로, 거기서 정리하면 DB 행이 남아 재로그인 시 "이미 접속중" 오탐이 발생한다.)
|
||||
*/
|
||||
@Component
|
||||
public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
|
||||
public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessHandler {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
|
||||
this.userSessionService = userSessionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 무효화 전에 실행되어 DB 세션 레코드를 정리하고 로그아웃 정보를 로깅한다.
|
||||
*/
|
||||
@Override
|
||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
public void logout(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) {
|
||||
HttpSession session = request.getSession(false);
|
||||
|
||||
if (session != null) {
|
||||
// DB 세션 레코드 정리 (중복세션 오탐 방지)
|
||||
userSessionService.removeSession(session.getId());
|
||||
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
@@ -97,7 +116,14 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃(세션 무효화) 완료 후 메인으로 리다이렉트한다.
|
||||
*/
|
||||
@Override
|
||||
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
response.sendRedirect(request.getContextPath() + "/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
@@ -39,7 +39,7 @@ gateway:
|
||||
|
||||
|
||||
portal:
|
||||
auth-virtual-code: 654321
|
||||
# auth-virtual-code: 654321
|
||||
test-auth-notice-enabled: true
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
@@ -11,6 +11,9 @@ spring:
|
||||
cachecontrol:
|
||||
max-age: 86400
|
||||
public: true
|
||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
@@ -26,4 +29,10 @@ spring:
|
||||
cache: false
|
||||
|
||||
portal:
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
resource-caching:
|
||||
enabled: true
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
|
||||
@@ -2,7 +2,9 @@ server:
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
timeout: 15m
|
||||
timeout: 10m
|
||||
cookie:
|
||||
name: PORTAL_JSESSIONID
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
port: '39130'
|
||||
@@ -33,12 +35,17 @@ spring:
|
||||
max-age: 0
|
||||
must-revalidate: true
|
||||
no-cache: true
|
||||
# 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
|
||||
# @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
|
||||
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
|
||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||
|
||||
jta:
|
||||
enabled: true
|
||||
enabled: false
|
||||
atomikos:
|
||||
properties:
|
||||
log-base-dir: /logs/atomikos
|
||||
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
|
||||
log-base-dir: /dev/null
|
||||
log-base-name: adp_tx
|
||||
|
||||
thymeleaf:
|
||||
@@ -49,6 +56,19 @@ spring:
|
||||
cache: false
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
|
||||
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: false
|
||||
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
resource-caching:
|
||||
enabled: false
|
||||
|
||||
security:
|
||||
basic:
|
||||
enabled: false
|
||||
@@ -235,12 +255,18 @@ page:
|
||||
corp:
|
||||
name: "법인회원가입"
|
||||
path: "/signup/portalOrg"
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/signup/verification-email"
|
||||
decision:
|
||||
name: "법인회원 초대"
|
||||
path: "/signup/decision_process"
|
||||
agreements:
|
||||
name: 약관
|
||||
path: "#"
|
||||
children:
|
||||
terms:
|
||||
name: "이용약관 및 개인정보처리방침"
|
||||
name: "이용약관"
|
||||
path: "/agreements/terms"
|
||||
about:
|
||||
name: 안내
|
||||
@@ -310,6 +336,9 @@ page:
|
||||
mypage:
|
||||
name: "내 정보 관리"
|
||||
path: "/mypage"
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/mypage/verification-email"
|
||||
user_list:
|
||||
name: "이용자 관리"
|
||||
path: "/users"
|
||||
|
||||
@@ -5,13 +5,20 @@
|
||||
|
||||
<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" />
|
||||
|
||||
<!-- 콘솔 로그 JVM 파라미터 제어 -->
|
||||
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
|
||||
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
|
||||
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
|
||||
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
|
||||
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
|
||||
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.false" value="OFF"/>
|
||||
<property name="__consoleLevel.FALSE" value="OFF"/>
|
||||
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
||||
|
||||
|
||||
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/hibernate.log</file>
|
||||
@@ -37,18 +44,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">
|
||||
@@ -62,14 +57,13 @@
|
||||
</appender>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||
</filter>
|
||||
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
|
||||
</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>
|
||||
@@ -77,6 +71,7 @@
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
@@ -85,62 +80,4 @@
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="stage">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 729 KiB |
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -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())
|
||||
|
||||
@@ -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,7 @@
|
||||
|
||||
// Color Palette - Vibrant and Modern
|
||||
// Primary colors
|
||||
$primary-blue: #0049b4; // 광주은행 블루
|
||||
$primary-blue: #0049b4; // Vibrant blue
|
||||
$secondary-blue: #c3dfea; // Deep blue
|
||||
$accent-cyan: #00D4FF; // Sky blue accent
|
||||
$accent-yellow: #FFD93D; // Yellow accent
|
||||
@@ -35,7 +35,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
|
||||
|
||||
@@ -1,130 +1,97 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/color-functions' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
// -----------------------------------------------------------------------------
|
||||
// Webfont @font-face declarations
|
||||
// - OneShinhan : 제목/강조 (Light 300, Medium 400·500, Bold 700)
|
||||
// - Spoqa Han Sans Neo / SpoqaHanSans : 본문 (Regular 400, Bold 700)
|
||||
// - Noto Sans KR : 한글 폴백 (Variable 100–900)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@font-face {
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-weight: 700;
|
||||
src: local('Spoqa Han Sans Neo Bold'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Bold.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Bold.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Bold.ttf') format('truetype')
|
||||
;
|
||||
//unicode-range: U+AC00-D7AF, U+0000-0040, U+005B-0060, U+007B-007F
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-weight: 400;
|
||||
src: local('Spoqa Han Sans Neo Regular'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Regular.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Regular.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansNeo-Regular.ttf') format('truetype')
|
||||
;
|
||||
//unicode-range: U+AC00-D7AF, U+0000-0040, U+005B-0060, U+007B-007F
|
||||
}
|
||||
|
||||
/* OneShinhan — 웹폰트 서빙 (제주은행 inbank/css/font/ 원본을 정적 리소스로 호스팅)
|
||||
weight 300=Light / 400&500=Medium / 700=Bold */
|
||||
/* OneShinhan */
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
src: local('원신한 Light'),
|
||||
local('OneShinhan Light'),
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Light'),
|
||||
local('OneShinhan-Light'),
|
||||
url('/font/djb/OneShinhanLight.woff') format('woff');
|
||||
url('/font/shinhan/OneShinhanLight.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
src: local('원신한 Medium'),
|
||||
local('OneShinhan Medium'),
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Medium'),
|
||||
local('OneShinhan-Medium'),
|
||||
url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
url('/font/shinhan/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
src: local('원신한 Medium'),
|
||||
local('OneShinhan Medium'),
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Medium'),
|
||||
local('OneShinhan-Medium'),
|
||||
url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
url('/font/shinhan/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OneShinhan';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
src: local('원신한 Bold'),
|
||||
local('OneShinhan Bold'),
|
||||
font-display: swap;
|
||||
src: local('OneShinhan Bold'),
|
||||
local('OneShinhan-Bold'),
|
||||
url('/font/djb/OneShinhanBold.woff') format('woff');
|
||||
}
|
||||
/* 한글 family name alias (변수에서 '원신한' 우선 매칭 시 동일 폰트 사용) */
|
||||
@font-face {
|
||||
font-family: '원신한';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
src: url('/font/djb/OneShinhanLight.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: '원신한';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: '원신한';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
src: url('/font/djb/OneShinhanMedium.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: '원신한';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
src: url('/font/djb/OneShinhanBold.woff') format('woff');
|
||||
url('/font/shinhan/OneShinhanBold.woff') format('woff');
|
||||
}
|
||||
|
||||
|
||||
// KJB PIB
|
||||
/* SpoqaHanSans */
|
||||
/* Spoqa Han Sans Neo */
|
||||
@font-face {
|
||||
font-family:'SpoqaHanSans';
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-weight: 400;
|
||||
src:url('/font/kjb/SpoqaHanSansRegular.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansRegular.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansRegular.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Neo Regular'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Regular.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family:'SpoqaHanSansBold';
|
||||
font-family: 'Spoqa Han Sans Neo';
|
||||
font-weight: 700;
|
||||
src:url('/font/kjb/SpoqaHanSansBold.woff') format('woff'),
|
||||
url('/font/kjb/SpoqaHanSansBold.woff2') format('woff2'),
|
||||
url('/font/kjb/SpoqaHanSansBold.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Neo Bold'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansNeo-Bold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* HGGGothicssi */
|
||||
/* SpoqaHanSans (Neo 미설치 환경 폴백) */
|
||||
@font-face {
|
||||
font-family:'HGGGothicssi';
|
||||
font-weight: 300;
|
||||
src:url('/font/kjb/HGGGothicssi40g.woff') format('woff'),
|
||||
url('/font/kjb/HGGGothicssi40g.woff2') format('woff2'),
|
||||
url('/font/kjb/HGGGothicssi40g.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family:'HGGGothicssi';
|
||||
font-family: 'SpoqaHanSans';
|
||||
font-weight: 400;
|
||||
src:url('/font/kjb/HGGGothicssi60g.woff') format('woff'),
|
||||
url('/font/kjb/HGGGothicssi60g.woff2') format('woff2'),
|
||||
url('/font/kjb/HGGGothicssi60g.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Regular'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansRegular.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansRegular.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansRegular.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family:'HGGGothicssi';
|
||||
font-family: 'SpoqaHanSans';
|
||||
font-weight: 700;
|
||||
src:url('/font/kjb/HGGGothicssi80g.woff') format('woff'),
|
||||
url('/font/kjb/HGGGothicssi80g.woff2') format('woff2'),
|
||||
url('/font/kjb/HGGGothicssi80g.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: local('Spoqa Han Sans Bold'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansBold.woff2') format('woff2'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansBold.woff') format('woff'),
|
||||
url('/font/spoqa-hans/SpoqaHanSansBold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Noto Sans KR (Variable) */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans KR';
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/font/noto-sans/NotoSansKR-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||