Compare commits
62 Commits
dfb37c18b9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 094336ebf1 | |||
| a310b671e2 | |||
| d9373b00f0 | |||
| 09bc3a65f5 | |||
| 1d81681e70 | |||
| d4bafa706c | |||
| 60f9f89d90 | |||
| 8ecfaf4945 | |||
| fa736a36ab | |||
| b7869ad6e6 | |||
| 982187298c | |||
| 8118dba74d | |||
| 97489d0361 | |||
| 924c00968c | |||
| 53c3b0ee7e | |||
| b2787882bf | |||
| aafa7ac351 | |||
| b7477e10a1 | |||
| 11f1dae1f3 | |||
| 59c379b22c | |||
| d275aa12c4 | |||
| c732f1e1b2 | |||
| 9e32502ee3 | |||
| 2d0ff2bf84 | |||
| 21940a3e18 | |||
| 2e517a1e08 | |||
| 10c5e51162 | |||
| 69e5126de6 | |||
| d044ebd157 | |||
| af40f0ba61 | |||
| d6b3a44d61 | |||
| e57f2fe72f | |||
| cecf6260a6 | |||
| 52707998d4 | |||
| 28017fd83c | |||
| e55b7a6bf2 | |||
| 1b6fed7701 | |||
| 033faa6714 | |||
| 758b3ebd7d | |||
| f07123904a | |||
| 2bc2a3d6e3 | |||
| b58893d098 | |||
| 80b072983f | |||
| a456ed9bb0 | |||
| 33c64972f8 | |||
| cff0199d2d | |||
| 2e920eec96 | |||
| 65a262849e | |||
| 7056306718 | |||
| 05e0599b26 | |||
| c223fa3124 | |||
| 78b1edff5a | |||
| b41a81bda3 | |||
| 1ea2c89776 | |||
| f1d6181210 | |||
| bac9a54dee | |||
| 603cba65a7 | |||
| 889d77b990 | |||
| bf2dad6ba7 | |||
| 3a1b0cb9c1 | |||
| a13279a777 | |||
| 5299352235 |
@@ -0,0 +1,28 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="eapim-portal [clean compileJava]" type="GradleRunConfiguration" factoryName="Gradle" nameIsGenerated="true">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="--no-build-cache -x test" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="clean" />
|
||||||
|
<option value="compileJava" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<GradleProfilingDisabled>false</GradleProfilingDisabled>
|
||||||
|
<GradleCoverageDisabled>false</GradleCoverageDisabled>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
+2
-1
@@ -74,8 +74,9 @@ pipeline {
|
|||||||
set -eu
|
set -eu
|
||||||
cd build/libs
|
cd build/libs
|
||||||
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||||
|
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||||
'''
|
'''
|
||||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||||
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ pipeline {
|
|||||||
sha256sum "$f" > "$f.sha256"
|
sha256sum "$f" > "$f.sha256"
|
||||||
md5sum "$f" > "$f.md5"
|
md5sum "$f" > "$f.md5"
|
||||||
done
|
done
|
||||||
|
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||||
'''
|
'''
|
||||||
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
|
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,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-6
@@ -87,6 +87,10 @@ dependencies {
|
|||||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||||
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
||||||
}
|
}
|
||||||
|
// WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265).
|
||||||
|
// 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환.
|
||||||
|
// 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가).
|
||||||
|
implementation 'ognl:ognl:3.3.5'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation('org.springframework.boot:spring-boot-starter-cache')
|
implementation('org.springframework.boot:spring-boot-starter-cache')
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
@@ -111,6 +115,14 @@ dependencies {
|
|||||||
implementation 'org.jasypt:jasypt:1.9.3'
|
implementation 'org.jasypt:jasypt:1.9.3'
|
||||||
implementation 'xerces:xercesImpl:2.12.2'
|
implementation 'xerces:xercesImpl:2.12.2'
|
||||||
|
|
||||||
|
// weblogic.xml prefer-application-packages 가 com.fasterxml.jackson.* 를 앱 우선으로 강제한다
|
||||||
|
// (oracle_common jackson-databind 2.17.0 vs 앱 2.18.10 split-package 방지, WLog 실사용 확인).
|
||||||
|
// Thymeleaf StandardJavaScriptSerializer 가 ObjectMapper.findAndRegisterModules() 로
|
||||||
|
// jackson-module-jaxb-annotations 를 옵션 모듈로 자동탐색하는데, 이 모듈은 WebLogic 번들에만
|
||||||
|
// 있고 앱엔 없어 ServiceConfigurationError(Provider not found) 발생 → 앱에도 명시 추가해
|
||||||
|
// jackson 전체를 자체 완결시킨다(jackson-bom 2.18.10 로 버전 통일).
|
||||||
|
implementation 'com.fasterxml.jackson.module:jackson-module-jaxb-annotations'
|
||||||
|
|
||||||
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
|
// Uncontrolled recursion in ClassUtils.getClass(...) on very long inputs. 3.18.0+ 에서 수정.
|
||||||
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
implementation 'org.apache.commons:commons-lang3:3.20.0'
|
||||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||||
@@ -182,13 +194,13 @@ ext {
|
|||||||
// 2.18.x 마지막 패치를 쓴다.
|
// 2.18.x 마지막 패치를 쓴다.
|
||||||
set('jackson-bom.version', '2.18.10')
|
set('jackson-bom.version', '2.18.10')
|
||||||
|
|
||||||
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
|
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
|
||||||
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
|
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
|
||||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
|
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
|
||||||
// org.thymeleaf.spring5 + javax.servlet 그대로다.
|
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
|
||||||
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
|
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
|
||||||
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
||||||
set('thymeleaf.version', '3.1.4.RELEASE')
|
set('thymeleaf.version', '3.1.5.RELEASE')
|
||||||
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
|
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
|
||||||
|
|
||||||
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||||
@@ -307,6 +319,18 @@ war {
|
|||||||
classpath = excludeLocalOnlyLibs(classpath)
|
classpath = excludeLocalOnlyLibs(classpath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 정적 리소스만 별도 zip (OHS 추가 배포용). war/bootWar 는 그대로 정적 리소스 포함 유지
|
||||||
|
// (WAS 단독 접속 인원 지원). sass/ 는 컴파일 소스이며 실제 서빙 경로에 없어 제외
|
||||||
|
// (PortalConfigWebDispatcherServlet#addResourceHandlers 기준).
|
||||||
|
task staticResourcesZip(type: Zip) {
|
||||||
|
archiveFileName = "eapim-portal-static.zip"
|
||||||
|
destinationDirectory = file("$buildDir/libs")
|
||||||
|
from('src/main/resources/static') {
|
||||||
|
exclude 'sass/**'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assemble.dependsOn staticResourcesZip
|
||||||
|
|
||||||
task printSourceSets {
|
task printSourceSets {
|
||||||
doLast {
|
doLast {
|
||||||
sourceSets.each { srcSet ->
|
sourceSets.each { srcSet ->
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
## OHS Static Resource 설정 예시
|
||||||
|
|
||||||
|
```apacheconf
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName weblogic-djb.rinjae.kr
|
||||||
|
|
||||||
|
<IfModule ossl_module>
|
||||||
|
SSLEngine on
|
||||||
|
SSLVerifyClient None
|
||||||
|
SSLProtocol TLSv1.2 TLSv1.3
|
||||||
|
SSLHonorCipherOrder on
|
||||||
|
SSLCipherSuite TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||||
|
SSLWallet "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/keystores/default"
|
||||||
|
|
||||||
|
<FilesMatch "\.(cgi|shtml|phtml|php)$">
|
||||||
|
SSLOptions +StdEnvVars
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<Directory "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/cgi-bin">
|
||||||
|
SSLOptions +StdEnvVars
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
BrowserMatch "MSIE [2-5]" \
|
||||||
|
nokeepalive ssl-unclean-shutdown \
|
||||||
|
downgrade-1.0 force-response-1.0
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set Strict-Transport-Security "max-age=63072000; preload; includeSubDomains"
|
||||||
|
</IfModule>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
Alias /css /apps/portal-static/css
|
||||||
|
Alias /js /apps/portal-static/js
|
||||||
|
Alias /img /apps/portal-static/img
|
||||||
|
Alias /images /apps/portal-static/images
|
||||||
|
Alias /webfonts /apps/portal-static/webfonts
|
||||||
|
Alias /font /apps/portal-static/font
|
||||||
|
Alias /html /apps/portal-static/html
|
||||||
|
Alias /plugins /apps/portal-static/plugins
|
||||||
|
Alias /favicon.png /apps/portal-static/favicon.png
|
||||||
|
|
||||||
|
<Directory "/apps/portal-static">
|
||||||
|
Require all granted
|
||||||
|
Options -Indexes
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<LocationMatch "^/favicon\.png$|^/(css|js|img|images|webfonts|font|html|plugins)/">
|
||||||
|
Header set Cache-Control "no-cache"
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<IfModule weblogic_module>
|
||||||
|
<Location />
|
||||||
|
DirectoryIndex disabled
|
||||||
|
SetHandler weblogic-handler
|
||||||
|
WebLogicHost 172.30.1.100
|
||||||
|
WebLogicPort 39130
|
||||||
|
DynamicServerList OFF
|
||||||
|
ConnectTimeoutSecs 10
|
||||||
|
ConnectRetrySecs 2
|
||||||
|
WLProxySSL ON
|
||||||
|
</Location>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<Location /css>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /js>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /img>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /images>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /webfonts>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /font>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /html>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /plugins>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /favicon.png>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import http.cookiejar
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = 'https://api.jejubank.co.kr'
|
||||||
|
COOKIE_NAME = 'JSESSIONID_PORTAL'
|
||||||
|
USER_AGENT = (
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||||
|
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||||
|
'Chrome/140.0.0.0 Safari/537.36'
|
||||||
|
)
|
||||||
|
COLUMNS = (
|
||||||
|
('회', 3),
|
||||||
|
('대상', 4),
|
||||||
|
('결과', 10),
|
||||||
|
('쿠키', 8),
|
||||||
|
('CSRF', 8),
|
||||||
|
('HTTP', 4),
|
||||||
|
('요청 값', 10),
|
||||||
|
('요청 서버ID', 11),
|
||||||
|
('응답 값', 10),
|
||||||
|
('응답 서버ID', 11),
|
||||||
|
)
|
||||||
|
BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+'
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, request, fp, code, message, headers, new_url):
|
||||||
|
# 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다.
|
||||||
|
# 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def integer_between(minimum, maximum):
|
||||||
|
def parse(value):
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||||
|
)
|
||||||
|
if not minimum <= number <= maximum:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||||
|
)
|
||||||
|
return number
|
||||||
|
return parse
|
||||||
|
|
||||||
|
|
||||||
|
def host_address(value):
|
||||||
|
value = value.strip()
|
||||||
|
message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.'
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlsplit(value)
|
||||||
|
parsed.port # 포트 형식과 범위도 검사한다.
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(message)
|
||||||
|
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
|
||||||
|
or parsed.path not in ('', '/') or parsed.query or parsed.fragment
|
||||||
|
or parsed.username is not None or parsed.password is not None
|
||||||
|
or any(char.isspace() for char in value)):
|
||||||
|
raise argparse.ArgumentTypeError(message)
|
||||||
|
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--host', type=host_address, default=DEFAULT_HOST,
|
||||||
|
metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--host2', type=host_address, metavar='주소',
|
||||||
|
help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--cookie-mode', choices=('shared', 'separate'), default='shared',
|
||||||
|
help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-n', '--count', type=integer_between(1, 100), default=10,
|
||||||
|
metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50,
|
||||||
|
metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)',
|
||||||
|
)
|
||||||
|
tls_options = parser.add_mutually_exclusive_group()
|
||||||
|
tls_options.add_argument(
|
||||||
|
'--cacert', metavar='CA파일',
|
||||||
|
help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)',
|
||||||
|
)
|
||||||
|
tls_options.add_argument(
|
||||||
|
'-k', '--insecure', action='store_true',
|
||||||
|
help='HTTPS 서버 인증서와 호스트명 검증 생략',
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def tls_context(args):
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
if args.cacert:
|
||||||
|
context.load_verify_locations(cafile=args.cacert)
|
||||||
|
if args.insecure:
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_from_headers(headers):
|
||||||
|
value = None
|
||||||
|
for header in headers:
|
||||||
|
parsed = SimpleCookie()
|
||||||
|
parsed.load(header)
|
||||||
|
if COOKIE_NAME in parsed:
|
||||||
|
value = parsed[COOKIE_NAME].value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_for_url(jar, url):
|
||||||
|
request = urllib.request.Request(url)
|
||||||
|
jar.add_cookie_header(request)
|
||||||
|
return cookie_from_headers([request.get_header('Cookie', '')])
|
||||||
|
|
||||||
|
|
||||||
|
def host_key(url):
|
||||||
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)
|
||||||
|
return parsed.scheme, parsed.hostname, port
|
||||||
|
|
||||||
|
|
||||||
|
def carry_session_cookie(request, value):
|
||||||
|
# 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다.
|
||||||
|
cookies = SimpleCookie()
|
||||||
|
cookies.load(request.get_header('Cookie', ''))
|
||||||
|
cookies[COOKIE_NAME] = value
|
||||||
|
request.add_unredirected_header(
|
||||||
|
'Cookie', cookies.output(header='', sep='; ').strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def server_id(cookie):
|
||||||
|
return cookie.split('!')[1] if cookie and '!' in cookie else '-'
|
||||||
|
|
||||||
|
|
||||||
|
def cell(value, width):
|
||||||
|
text = str(value)
|
||||||
|
# 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다.
|
||||||
|
display_width = sum(
|
||||||
|
0 if unicodedata.combining(char) else
|
||||||
|
2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
|
||||||
|
for char in text
|
||||||
|
)
|
||||||
|
return text + ' ' * max(0, width - display_width)
|
||||||
|
|
||||||
|
|
||||||
|
def print_row(values):
|
||||||
|
print('| ' + ' | '.join(
|
||||||
|
cell(value, width) for value, (_, width) in zip(values, COLUMNS)
|
||||||
|
) + ' |', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def change_state(previous, current):
|
||||||
|
if previous is None:
|
||||||
|
return '최초'
|
||||||
|
return '유지' if previous == current else '!!변경!!'
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe(args):
|
||||||
|
targets = [('A', args.host + '/api/session/csrf')]
|
||||||
|
if args.host2:
|
||||||
|
targets.append(('B', args.host2 + '/api/session/csrf'))
|
||||||
|
try:
|
||||||
|
context = tls_context(args)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
print(f'TLS 설정 실패: {error}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
cookie_processor = urllib.request.HTTPCookieProcessor(jar)
|
||||||
|
handlers = [
|
||||||
|
urllib.request.HTTPSHandler(context=context),
|
||||||
|
cookie_processor,
|
||||||
|
]
|
||||||
|
if args.host2:
|
||||||
|
handlers.append(NoRedirectHandler())
|
||||||
|
client = urllib.request.build_opener(*handlers)
|
||||||
|
previous_cookie = None
|
||||||
|
previous_token = None
|
||||||
|
cookie_stores = {}
|
||||||
|
session_states = {}
|
||||||
|
totals = {'최초': 0, '유지': 0, '변경': 0}
|
||||||
|
completed = 0
|
||||||
|
failure = None
|
||||||
|
exit_code = 0
|
||||||
|
|
||||||
|
for target, url in targets:
|
||||||
|
print(f'대상 {target}: {url}')
|
||||||
|
if len(targets) == 2:
|
||||||
|
print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)')
|
||||||
|
print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.')
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리')
|
||||||
|
elif len(targets) == 2:
|
||||||
|
print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달')
|
||||||
|
if any(url.startswith('https://') for _, url in targets):
|
||||||
|
tls_mode = ('생략 (--insecure)' if args.insecure else
|
||||||
|
f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용')
|
||||||
|
print(f'TLS 인증서 검증: {tls_mode}')
|
||||||
|
print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms')
|
||||||
|
print(BORDER)
|
||||||
|
print_row([title for title, _ in COLUMNS])
|
||||||
|
print(BORDER, flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for number in range(1, args.count + 1):
|
||||||
|
if number > 1 and args.delay:
|
||||||
|
time.sleep(args.delay / 1000)
|
||||||
|
|
||||||
|
target, url = targets[(number - 1) % len(targets)]
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
key = host_key(url)
|
||||||
|
if key not in cookie_stores:
|
||||||
|
cookie_stores[key] = http.cookiejar.CookieJar()
|
||||||
|
jar = cookie_stores[key]
|
||||||
|
# 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다.
|
||||||
|
cookie_processor.cookiejar = jar
|
||||||
|
previous_cookie, previous_token = session_states.get(key, (None, None))
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다.
|
||||||
|
jar.add_cookie_header(request)
|
||||||
|
if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None:
|
||||||
|
carry_session_cookie(request, previous_cookie)
|
||||||
|
request_cookie = cookie_from_headers([request.get_header('Cookie', '')])
|
||||||
|
response_cookie = None
|
||||||
|
status = '-'
|
||||||
|
try:
|
||||||
|
with client.open(request, timeout=10) as response:
|
||||||
|
status = response.status
|
||||||
|
response_url = response.geturl()
|
||||||
|
# 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'.
|
||||||
|
response_cookie = cookie_from_headers(
|
||||||
|
response.headers.get_all('Set-Cookie') or []
|
||||||
|
)
|
||||||
|
data = json.load(response)
|
||||||
|
|
||||||
|
if args.host2 and args.cookie_mode == 'shared' and response_cookie is None:
|
||||||
|
# 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다.
|
||||||
|
# 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다.
|
||||||
|
cookie = request_cookie
|
||||||
|
else:
|
||||||
|
cookie = cookie_for_url(jar, response_url)
|
||||||
|
token = data.get('token') if isinstance(data, dict) else None
|
||||||
|
if not cookie or not token:
|
||||||
|
raise ValueError('세션 쿠키 또는 CSRF 토큰 없음')
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
status = error.code
|
||||||
|
response_cookie = cookie_from_headers(
|
||||||
|
error.headers.get_all('Set-Cookie') or []
|
||||||
|
)
|
||||||
|
failure = f'{number}회: HTTP {status}'
|
||||||
|
error.close()
|
||||||
|
except Exception as error:
|
||||||
|
failure = f'{number}회: {error}'
|
||||||
|
|
||||||
|
if failure:
|
||||||
|
result, cookie_state, token_state = '!!실패!!', '-', '-'
|
||||||
|
exit_code = 1
|
||||||
|
else:
|
||||||
|
# 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다.
|
||||||
|
cookie_state = change_state(previous_cookie, cookie)
|
||||||
|
token_state = change_state(previous_token, token)
|
||||||
|
if '!!변경!!' in (cookie_state, token_state):
|
||||||
|
outcome, result = '변경', '>>>변경<<<'
|
||||||
|
elif previous_cookie is None:
|
||||||
|
outcome, result = '최초', '[최초]'
|
||||||
|
else:
|
||||||
|
outcome, result = '유지', '[유지]'
|
||||||
|
totals[outcome] += 1
|
||||||
|
completed += 1
|
||||||
|
previous_cookie, previous_token = cookie, token
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
session_states[host_key(url)] = (cookie, token)
|
||||||
|
|
||||||
|
print_row((
|
||||||
|
number, target, result, cookie_state, token_state, status,
|
||||||
|
request_cookie[:10] if request_cookie else '없음',
|
||||||
|
server_id(request_cookie),
|
||||||
|
response_cookie[:10] if response_cookie else '없음',
|
||||||
|
server_id(response_cookie),
|
||||||
|
))
|
||||||
|
if failure:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
failure = '사용자가 중단했습니다.'
|
||||||
|
exit_code = 130
|
||||||
|
|
||||||
|
print(BORDER)
|
||||||
|
print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}회')
|
||||||
|
print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.')
|
||||||
|
if failure:
|
||||||
|
print(f'실패/중단: {failure}')
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = parse_args(argv)
|
||||||
|
started_counter = time.perf_counter()
|
||||||
|
started_at = datetime.now().astimezone()
|
||||||
|
print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True)
|
||||||
|
try:
|
||||||
|
return run_probe(args)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('실패/중단: 사용자가 중단했습니다.')
|
||||||
|
return 130
|
||||||
|
finally:
|
||||||
|
finished_at = datetime.now().astimezone()
|
||||||
|
# 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다.
|
||||||
|
elapsed = time.perf_counter() - started_counter
|
||||||
|
print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}')
|
||||||
|
print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!--
|
||||||
|
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
|
||||||
|
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
|
||||||
|
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
|
||||||
|
-->
|
||||||
|
<h1>개인정보수집동의서</h1>
|
||||||
|
<p><strong>(주)제주은행 귀중</strong></p>
|
||||||
|
<p>귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.</p>
|
||||||
|
<h2>개인정보</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>일반 개인정보:</strong> 성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호</li>
|
||||||
|
</ul>
|
||||||
|
<p>□ 동의하지 않음 □ 동의함</p>
|
||||||
|
<p>※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.</p>
|
||||||
|
<p>※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.</p>
|
||||||
|
<h3>위 개인정보 수집·이용에 동의하십니까?</h3>
|
||||||
|
<p>제정일자: 2026. 99. 99. 개정</p>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
개인정보수집동의서
|
||||||
|
|
||||||
|
(주)제주은행 귀중
|
||||||
|
|
||||||
|
귀 행과의 Open API Market 회원가입 거래와 관련하여 귀 행이 본인의 개인정보를 수집·이용하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 본인의 동의가 필요합니다.
|
||||||
|
|
||||||
|
개인정보
|
||||||
|
ㄴ 일반 개인정보
|
||||||
|
성명, 직장명, 부서명, 연락처(휴대폰 번호, 직장번호), 이메일주소, 아이디, 비밀번호
|
||||||
|
|
||||||
|
□ 동의하지 않음 □ 동의함
|
||||||
|
|
||||||
|
※ 본 동의 이후 동일한 목적 또는 이용범위 내에서 개인정보의 정확성, 최신성을 유지하기 위해 제공하는 경우에는 별도의 추가 동의가 필요하지 않습니다.
|
||||||
|
|
||||||
|
※ 은행의 고의 또는 과실 등 귀책사유로 인한 개인정보 유출로 고객님에게 발생한 손해에 대해 관계 법령 등에 따라 보상받으실 수 있습니다.
|
||||||
|
|
||||||
|
위 개인정보 수집·이용에 동의하십니까?
|
||||||
|
|
||||||
|
제정일자: 2026. 99. 99. 개정
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<!--
|
||||||
|
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h2/h3/p)만 사용.
|
||||||
|
admin Summernote 코드뷰에 그대로 붙여넣으면 됨(붙여넣기 시 badTags 정리 대상 없음).
|
||||||
|
약관탭(.content-body.editor-content), 회원가입 동의창, admin .note-editable.editor-content 3곳 모두
|
||||||
|
이 파일 안에서는 정의하지 않는 h2/h3/p 스타일은 공용 editor-content.css 가 담당한다.
|
||||||
|
-->
|
||||||
|
<h2>제1장 총칙</h2>
|
||||||
|
<h3>제1조 (목적)</h3>
|
||||||
|
<p>본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.</p>
|
||||||
|
<h3>제2조 (용어의 정의)</h3>
|
||||||
|
<p> ① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.</p>
|
||||||
|
<p> 1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API</p>
|
||||||
|
<p> 2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것</p>
|
||||||
|
<p> 3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템</p>
|
||||||
|
<p> 4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트</p>
|
||||||
|
<p> 5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)</p>
|
||||||
|
<p> 6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자</p>
|
||||||
|
<p> 7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객</p>
|
||||||
|
<p> 8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자</p>
|
||||||
|
<h3>제3조 (서비스의 내용)</h3>
|
||||||
|
<p> ① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)</p>
|
||||||
|
<p> ② 거래성 API 서비스(환전, 이체 등)</p>
|
||||||
|
<p> ③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”</p>
|
||||||
|
<h3>제4조 (약관의 효력 및 변경)</h3>
|
||||||
|
<p> ① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.</p>
|
||||||
|
<p> ② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.</p>
|
||||||
|
<h2>제2장 서비스 이용계약</h2>
|
||||||
|
<h3>제5조 (서비스 이용신청)</h3>
|
||||||
|
<p> ① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.</p>
|
||||||
|
<p> ② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.</p>
|
||||||
|
<h3>제6조 (서비스 이용신청 승인 및 거절)</h3>
|
||||||
|
<p> ① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.</p>
|
||||||
|
<p> ② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.</p>
|
||||||
|
<p> 1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우</p>
|
||||||
|
<p> 2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우</p>
|
||||||
|
<p> 3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우</p>
|
||||||
|
<p> 4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우</p>
|
||||||
|
<p> 5. “이용신청기관”의 사업모델에 위법성이 있는 경우</p>
|
||||||
|
<p> 6. “이용신청기관”의 재무안정성이 위험한 경우</p>
|
||||||
|
<p> 7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우</p>
|
||||||
|
<p> 8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우</p>
|
||||||
|
<p> ③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.</p>
|
||||||
|
<h2>제3장 서비스의 이용</h2>
|
||||||
|
<h3>제7조 (서비스 이용개시)</h3>
|
||||||
|
<p>“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.</p>
|
||||||
|
<h3>제8조 (서비스 이용시간)</h3>
|
||||||
|
<p> ① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.</p>
|
||||||
|
<p> 1. 은행의 시스템을 점검하는 경우</p>
|
||||||
|
<p> 2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우</p>
|
||||||
|
<p> 3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우</p>
|
||||||
|
<p> 4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우</p>
|
||||||
|
<p> 5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우</p>
|
||||||
|
<p> ② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.</p>
|
||||||
|
<h3>제9조 (서비스 변경 및 중단)</h3>
|
||||||
|
<p> ① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.</p>
|
||||||
|
<p> ② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.</p>
|
||||||
|
<p> 1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우</p>
|
||||||
|
<p> 2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우</p>
|
||||||
|
<p> 3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우</p>
|
||||||
|
<p> 4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우</p>
|
||||||
|
<p> ③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||||
|
<p> ④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||||
|
<h3>제10조 (서비스 이용 제한)</h3>
|
||||||
|
<p> ① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.</p>
|
||||||
|
<p> 1. 감독당국으로부터 중지 권고가 있는 경우</p>
|
||||||
|
<p> 2. “이용기관”이 휴업 또는 폐업을 하는 경우</p>
|
||||||
|
<p> 3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우</p>
|
||||||
|
<p> 4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우</p>
|
||||||
|
<p> 5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우</p>
|
||||||
|
<p> 6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우</p>
|
||||||
|
<p> ② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.</p>
|
||||||
|
<h3>제11조 (서비스 이용 해지)</h3>
|
||||||
|
<p> ① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.</p>
|
||||||
|
<p> 1. 감독당국으로부터 해지권고가 있는 경우</p>
|
||||||
|
<p> 2. 최근 12개월간 “시스템” 이용 실적이 없는 경우</p>
|
||||||
|
<p> 3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우</p>
|
||||||
|
<p> 4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우</p>
|
||||||
|
<p> ② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.</p>
|
||||||
|
<p> ③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.</p>
|
||||||
|
<h2>제4장 계약당사자의 의무 및 손해배상 등</h2>
|
||||||
|
<h3>제12조 (은행의 의무)</h3>
|
||||||
|
<p> ① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.</p>
|
||||||
|
<p> ② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.</p>
|
||||||
|
<p> ③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.</p>
|
||||||
|
<p> ④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
|
||||||
|
<h3>제13조 (약관 외 준칙)</h3>
|
||||||
|
<p> ① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.</p>
|
||||||
|
<p> ② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.</p>
|
||||||
|
<p> ③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.</p>
|
||||||
|
<p> ④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.</p>
|
||||||
|
<p> ⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.</p>
|
||||||
|
<p> ⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.</p>
|
||||||
|
<p> ⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.</p>
|
||||||
|
<p> ⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.</p>
|
||||||
|
<p> 1. 다른 “이용기관”의 아이디를 부정 사용하는 행위</p>
|
||||||
|
<p> 2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위</p>
|
||||||
|
<p> 3. 선량한 풍속, 기타 사회질서를 해하는 행위</p>
|
||||||
|
<p> 4. 타인의 명예를 훼손하거나 모욕하는 행위</p>
|
||||||
|
<p> 5. 타인의 지식재산권 등의 권리를 침해하는 행위</p>
|
||||||
|
<p> 6. 해킹행위 또는 컴퓨터 바이러스의 유포행위</p>
|
||||||
|
<p> 7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위</p>
|
||||||
|
<p> 8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위</p>
|
||||||
|
<p> ⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.</p>
|
||||||
|
<h2>제5장 수수료</h2>
|
||||||
|
<h3>제14조 (수수료)</h3>
|
||||||
|
<p> ① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.</p>
|
||||||
|
<p> ② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.</p>
|
||||||
|
<p> ③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.</p>
|
||||||
|
<p> ④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.</p>
|
||||||
|
<p> ⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.</p>
|
||||||
|
<p> ⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.</p>
|
||||||
|
<h2>제6장 보칙</h2>
|
||||||
|
<h3>제15조 (최종사용자에 대한 고지 의무)</h3>
|
||||||
|
<p> ① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.</p>
|
||||||
|
<p> ② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.</p>
|
||||||
|
<p> ③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.</p>
|
||||||
|
<p> ④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.</p>
|
||||||
|
<h3>제16조 (부정 접속 방지)</h3>
|
||||||
|
<p> ① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.</p>
|
||||||
|
<p> ② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.</p>
|
||||||
|
<h3>제17조 (전산처리 절차)</h3>
|
||||||
|
<p>전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.</p>
|
||||||
|
<h3>제18조 (보안관리)</h3>
|
||||||
|
<p> ① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.</p>
|
||||||
|
<p> 1. 개인정보 및 금융정보 암호화 운영현황</p>
|
||||||
|
<p> 2. 해킹 등에 대비한 정책사항</p>
|
||||||
|
<p> 3. 시스템 운영에 대한 정보보호 체계 등</p>
|
||||||
|
<p> ② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.</p>
|
||||||
|
<p> ③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.</p>
|
||||||
|
<h3>제19조 (기밀유지)</h3>
|
||||||
|
<p> ① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.</p>
|
||||||
|
<p> ② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.</p>
|
||||||
|
<h3>제20조 (손해배상 및 면책)</h3>
|
||||||
|
<p> ① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
|
||||||
|
<p> ② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.</p>
|
||||||
|
<p> 1. 접근매체의 위조나 변조로 발생한 사고</p>
|
||||||
|
<p> 2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고</p>
|
||||||
|
<p> 3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고</p>
|
||||||
|
<p> ③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.</p>
|
||||||
|
<p> ④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.</p>
|
||||||
|
<p> 1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)</p>
|
||||||
|
<p> 2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우</p>
|
||||||
|
<p> 3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우</p>
|
||||||
|
<p> 4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우</p>
|
||||||
|
<p> 가. 누설·노출 또는 방치한 행위</p>
|
||||||
|
<p> 나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위</p>
|
||||||
|
<p> 5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우</p>
|
||||||
|
<h3>제21조 (이의제기 및 협조)</h3>
|
||||||
|
<p> ① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.</p>
|
||||||
|
<p> ② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.</p>
|
||||||
|
<p> ③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.</p>
|
||||||
|
<p> ④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.</p>
|
||||||
|
<h3>제22조 (거래기록·자료의 제공)</h3>
|
||||||
|
<p> ① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.</p>
|
||||||
|
<p> ② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.</p>
|
||||||
|
<p> ③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.</p>
|
||||||
|
<p> ④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.</p>
|
||||||
|
<p> ⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.</p>
|
||||||
|
<p> 1. 전자적 장치를 통한 제공의 경우에는 즉시</p>
|
||||||
|
<p> 2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부</p>
|
||||||
|
<h2>부칙</h2>
|
||||||
|
<p>본 약관은 2026. 99. 99.부터 시행합니다.</p>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
제1장 총칙
|
||||||
|
|
||||||
|
제1조 (목적)
|
||||||
|
본 약관은 (주)제주은행(이하 “은행”)이 제공하는 Open API 서비스를 이용함에 있어 “은행”과 Open API 서비스를 이용하는 자(이하 “이용기관”)간의 권리, 의무 및 책임 사항, 이용조건 및 절차 등 기본적인 사항을 명확히 규정함을 목적으로 합니다.
|
||||||
|
|
||||||
|
제2조 (용어의 정의)
|
||||||
|
① 본 약관에서 사용하는 용어의 정의는 다음 각 호와 같습니다.
|
||||||
|
1. “Open API”란 “은행”이 “이용기관”에게 제공하는 API(Application Programming Interface) 기능을 개방형 플랫폼 형태로 이용할 수 있도록 공개한 API
|
||||||
|
2. “Open API 서비스”(이하 “서비스”)란 “은행”의 서비스 및 데이터를 표준화된 API 형태로 Open API 시스템을 통하여 “이용기관”에 제공하는 것
|
||||||
|
3. “Open API 시스템”(이하 “시스템”)이란 “서비스”를 제공하기 위하여 “은행”에 설치한 중계시스템과 “이용기관”의 처리시스템을 통신회선으로 연결하여 “은행” 및 “이용기관” 상호간에 정보를 교환하는 시스템
|
||||||
|
4. “Open API 포탈”(이하 “포털”)이란 “서비스”를 소개하고 가입 절차 및 이용 절차를 제공하며 “이용기관”이 가입 및 “서비스” 이용을 하기 위하여 “은행”이 제공하는 웹사이트
|
||||||
|
5. “이용신청기관”이란 “은행”의 “서비스”를 이용하기 위하여 “서비스” 이용을 신청한 기관(법인, 개인사업자 및 임의단체 등)
|
||||||
|
6. “이용기관”이란 본 약관에 동의하고 “서비스” 이용신청에 대한 “은행”의 승낙을 받은 후, “은행”이 제공하는 Open API를 이용하여 별도의 서비스 또는 프로그램을 개발, 테스트 및 제공하고자 하는 자
|
||||||
|
7. “최종사용자”란 “은행”이 제공한 “서비스”를 결합하여 “이용기관”이 개발한 별도의 서비스 또는 프로그램을 이용하는 고객
|
||||||
|
8. “제휴사”란 “은행”이 “이용기관”에게 “서비스”를 제공하기 위하여 “은행”과 계약을 체결하여 협력하는 제3자
|
||||||
|
|
||||||
|
제3조 (서비스의 내용)
|
||||||
|
① 조회성 API 서비스(계좌 거래내역 조회, 예금주 실명 조회 등)
|
||||||
|
② 거래성 API 서비스(환전, 이체 등)
|
||||||
|
③ 기타 “은행” 또는 “제휴사”가 제공하는 API 형태의 “서비스”
|
||||||
|
|
||||||
|
제4조 (약관의 효력 및 변경)
|
||||||
|
① 본 약관 내용의 효력은 “은행” 인터넷 홈페이지 또는 “포털” 등에 게시되거나 전자우편 등의 방법으로 “이용기관”에 통지됨으로써 발생합니다.
|
||||||
|
② 본 약관의 변경은 전자금융거래 기본약관의 내용을 준용합니다.
|
||||||
|
|
||||||
|
제2장 서비스 이용계약
|
||||||
|
|
||||||
|
제5조 (서비스 이용신청)
|
||||||
|
① 서비스 이용신청은 “이용신청기관”이 “포털”에서 “은행”이 정한 가입양식에 회원정보 기입 및 본 약관에 동의함으로써 이루어집니다.
|
||||||
|
② 제1항을 완료한 “이용신청기관”은 이용 희망일 전일까지 “은행”이 요청하는 서류를 제출하여야 합니다.
|
||||||
|
|
||||||
|
제6조 (서비스 이용신청 승인 및 거절)
|
||||||
|
① “은행”은 본 약관 제5조 제2항의 “이용신청기관”이 제출한 서류와 “서비스” 이용목적의 적정성을 검토한 후, “이용신청기관” 서비스 이용신청을 승낙 또는 거절할 수 있습니다.
|
||||||
|
② “은행”은 “이용신청기관”이 다음 각 호의 어느 하나에 해당할 경우 이용신청에 대해 거절할 수 있습니다.
|
||||||
|
1. 실명이 아니거나, 타인의 명의를 사용하여 신청한 경우
|
||||||
|
2. 이용신청 내용을 허위로 기재하거나 허위서류를 첨부한 경우
|
||||||
|
3. 이전에 “이용기관” 자격을 상실한 적이 있거나, 탈퇴, 이용중지 등이 있는 경우
|
||||||
|
4. “이용신청기관”의 업태가 사행행위에 관련이 있거나 금융질서문란 위험이 있는 경우
|
||||||
|
5. “이용신청기관”의 사업모델에 위법성이 있는 경우
|
||||||
|
6. “이용신청기관”의 재무안정성이 위험한 경우
|
||||||
|
7. “이용신청기관”의 전산망이나 정보처리시스템에 보안 취약점이 있거나, “이용신청기관”의 “최종사용자” 정보에 관한 기술적·물리적·관리적 보호조치가 미흡한 경우
|
||||||
|
8. “은행”의 평판이나 “최종사용자”의 권리에 위험을 끼칠 가능성이 있는 경우
|
||||||
|
③ “은행”은 이용신청에 대한 결과를 “이용신청자”에게 통보하여야 합니다.
|
||||||
|
|
||||||
|
제3장 서비스의 이용
|
||||||
|
|
||||||
|
제7조 (서비스 이용개시)
|
||||||
|
“은행”이 “이용신청기관”의 이용신청을 심사 후 승낙하게 되면 “이용기관”이 요청한 이용 희망일로부터 “서비스”를 개시합니다. 단, 이용 희망일은 “은행”과 협의하여 결정합니다.
|
||||||
|
|
||||||
|
제8조 (서비스 이용시간)
|
||||||
|
① “서비스”의 이용시간은 연중무휴(24시간)로 하며 “서비스”별 이용시간을 정하여 운영할 수 있습니다. 단, 다음 각 호의 어느 하나에 해당하여 은행이 정상적인 “서비스”를 제공하기 어려운 경우에는 서비스의 전부 또는 일부가 제한 될 수 있습니다.
|
||||||
|
1. 은행의 시스템을 점검하는 경우
|
||||||
|
2. 은행의 정보통신 설비의 보수점검, 증설, 교체, 이전 등 시스템 관리 작업을 진행하는 경우
|
||||||
|
3. 정전, 제반 설비의 장애 또는 이용량의 폭주 등이 발생하는 경우
|
||||||
|
4. 천재지변, 전쟁, 폭동, 테러, 해킹, Ddos, 국가비상사태 등 불가항력적 사유가 발생하는 경우
|
||||||
|
5. 시스템 오류 등 서비스 제공에 관한 기술상 문제가 발생하는 경우
|
||||||
|
② 제1항의 사유로 서비스가 일시 중지되는 경우, “은행”은 사전에 이를 공지하여야 합니다. 단, 부득이한 경우 사후에 통보할 수 있습니다.
|
||||||
|
|
||||||
|
제9조 (서비스 변경 및 중단)
|
||||||
|
① “은행”은 변경될 “서비스”의 내용 및 변경일자를 “이용기관”에게 통지하고 “서비스”를 변경하여 제공할 수 있습니다.
|
||||||
|
② “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “서비스”의 전부 또는 일부를 중단할 수 있습니다.
|
||||||
|
1. “서비스”용 정보통신설비의 보수·교체·고장, 통신두절 등으로 인해 부득이한 경우
|
||||||
|
2. 정전, 모든 설비의 장애 또는 이용량의 폭주 등으로 정상적인 서비스 이용에 지장이 있는 경우
|
||||||
|
3. “서비스” 종료 등과 같은 “은행”의 사정으로 “서비스”를 유지할 수 없는 경우
|
||||||
|
4. 기타 천재지변, 국가비상사태 등 불가항력적 사유가 있는 경우
|
||||||
|
③ “서비스”를 변경하거나 중단할 경우, “은행”은 그 사실을 사전에 “이용기관”에 통지하여야 합니다. 단, 사전에 통지할 수 없는 부득이한 사유가 있는 경우에는 사후에 그 사유와 함께 통지하여야 합니다. 또한 “포털”에 등록된 연락처가 부정확하거나 “이용기관” 측의 사유로 인하여 “은행”의 통보를 받지 못해 발생하는 손실에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
|
||||||
|
④ “은행”은 “서비스”의 변경 혹은 중단으로 발생하는 문제에 대해서는 “은행”의 고의 또는 과실이 없는 경우 그 책임의 전부 또는 일부를 지지 않습니다.
|
||||||
|
|
||||||
|
제10조 (서비스 이용 제한)
|
||||||
|
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 제한할 수 있습니다.
|
||||||
|
1. 감독당국으로부터 중지 권고가 있는 경우
|
||||||
|
2. “이용기관”이 휴업 또는 폐업을 하는 경우
|
||||||
|
3. 접근권한을 넘어서는 접속을 시도하거나 반복적인 접속을 시도하여 과부하 발생 등 시스템 운영의 안전성을 저해한다고 판단되는 행위가 확인된 경우
|
||||||
|
4. “이용기관” 수수료 납부계좌에서 부족분이 3회 이상 발생할 경우
|
||||||
|
5. “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 발생한 경우 또는 “서비스” 신청 당시 “이용기관”에 본 약관 제6조 제2항 각 호의 사유가 있었음이 밝혀진 경우
|
||||||
|
6. “시스템” 및 “포털”의 안정적인 운영을 위하여 필요하다고 인정되는 경우
|
||||||
|
② “이용기관”이 제1항에 해당할 경우, “은행”은 “서비스” 이용을 제한할 수 있으며 이와 별도로 해당 “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다. 단, “은행”은 “이용기관”의 소명자료가 정당하다고 판단하기 전까지 “이용기관”의 “서비스” 이용을 제한할 수 있습니다.
|
||||||
|
|
||||||
|
제11조 (서비스 이용 해지)
|
||||||
|
① “은행”은 “이용기관”이 다음 각 호의 어느 하나에 해당할 경우 “이용기관”에 대한 “서비스” 이용을 개별 고지 후 해지할 수 있습니다.
|
||||||
|
1. 감독당국으로부터 해지권고가 있는 경우
|
||||||
|
2. 최근 12개월간 “시스템” 이용 실적이 없는 경우
|
||||||
|
3. “이용기관” 수수료 납부계좌에서 부족분이 6회 이상 발생할 경우
|
||||||
|
4. “이용기관”의 정상적인 운영이 불가능하다고 “은행”이 판단할 경우
|
||||||
|
② “이용기관”이 제1항에 해당할 경우, “이용기관”에 소명자료 제출을 요구할 수 있습니다. “이용기관”은 통지받은 날로부터 15일 이내에 소명자료를 제출하여야 합니다.
|
||||||
|
③ 해지일은 “은행”이 “시스템” 또는 “포털” 원장에 해지 등록한 날로 합니다.
|
||||||
|
|
||||||
|
제4장 계약당사자의 의무 및 손해배상 등
|
||||||
|
|
||||||
|
제12조 (은행의 의무)
|
||||||
|
① “은행”은 본 약관에서 특별히 규정한 사유 외에는 “서비스”를 지속적·안정적으로 제공하여야 합니다.
|
||||||
|
② “은행”은 “서비스” 제공과 관련하여 알게 된 “이용기관”의 정보를 업무 이외의 목적으로 사용할 수 없습니다. 단, 관계법령에 따른 수사상의 관계기관으로부터 요구 받은 경우나 정보통신 윤리위원회의 요청이 있는 경우 등 법률의 규정에 따른 적법한 절차에 의한 경우에는 그러하지 않습니다.
|
||||||
|
③ “은행”이 제공하는 “서비스”로 인하여 “이용기관”에 손해가 발생한 경우 “서비스”를 제공하는 “은행”과 이용하는 “이용기관”이 상호협의에 의하여 해결합니다. 단, 그러한 손해가 “은행”의 고의나 과실에 기해 발생한 경우에 한하여 “은행”은 그 책임의 전부 또는 일부를 부담합니다.
|
||||||
|
④ “은행”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
|
||||||
|
|
||||||
|
제13조 (약관 외 준칙)
|
||||||
|
① “이용기관”은 “포털”에서 “은행”이 정한 가입양식에 회원정보를 기입할 때, 사실에 근거하여 정확하게 기입하여야 합니다.
|
||||||
|
② “이용기관”은 “은행”에 제공한 사항이 변경될 때, 즉시 “은행”에 변경 신청하여 변경사항을 최신의 정보로 수정해야 합니다. 수정하지 않은 정보로 인하여 발생하는 문제에 대한 책임은 “이용기관”에 있으며 “은행”은 이에 대해 아무런 책임을 지지 않습니다.
|
||||||
|
③ “이용기관”은 “시스템”과 관련한 “서비스” 이용의 대가로 수수료를 납부하여야 합니다.
|
||||||
|
④ “이용기관”은 “서비스”의 이용 권한, 기타 이용 계약상의 지위를 타인에게 양도, 증여할 수 없으며 게시물에 대한 저작권을 포함한 모든 권리 및 책임은 이를 게시한 “이용기관”에 있습니다.
|
||||||
|
⑤ “이용기관”은 “서비스”의 안정적인 이용을 위해 전산시스템의 구축에 필요한 회선, 단말기 및 부대기기를 “이용기관”의 부담으로 설치하여야 하고, 보안상 안전하게 처리할 수 있는 전산시스템을 갖추어야 합니다.
|
||||||
|
⑥ “이용기관”은 “은행”이나 “최종사용자”가 업무처리에 대하여 이의를 제기하거나 확인을 요청하면 이를 신속히 처리하여야 합니다.
|
||||||
|
⑦ “이용기관”은 본 약관에서 정한 사항을 준수하여야 하며, “은행”이 “이용기관”의 약관 이행여부를 점검하고자 할 경우 적극 협조하여야 합니다.
|
||||||
|
⑧ “이용기관”은 “포털”에서 제공하는 “서비스” 이용과 관련하여 다음 각 호의 행위를 하여서는 안 됩니다.
|
||||||
|
1. 다른 “이용기관”의 아이디를 부정 사용하는 행위
|
||||||
|
2. 범죄행위를 목적으로 하거나 기타 범죄행위와 관련된 행위
|
||||||
|
3. 선량한 풍속, 기타 사회질서를 해하는 행위
|
||||||
|
4. 타인의 명예를 훼손하거나 모욕하는 행위
|
||||||
|
5. 타인의 지식재산권 등의 권리를 침해하는 행위
|
||||||
|
6. 해킹행위 또는 컴퓨터 바이러스의 유포행위
|
||||||
|
7. 타인의 의사에 반하여 광고성 정보 등 일정한 내용을 지속적으로 전송하는 행위
|
||||||
|
8. 서비스의 안정적인 운영에 지장을 주거나 줄 우려가 있는 일체의 행위
|
||||||
|
⑨ “이용기관”은 개인정보보호법, 신용정보의 이용 및 보호에 관한 법률, 금융실명거래 및 비밀보장에 관한 법률, 정보통신망 이용촉진 및 정보보호에 관한 법률, 통신비밀보호법 등 “서비스” 운영, 유지와 관련 있는 법규를 준수하여야 합니다.
|
||||||
|
|
||||||
|
제5장 수수료
|
||||||
|
|
||||||
|
제14조 (수수료)
|
||||||
|
① “은행”은 “서비스” 제공의 대가로 “이용기관”에게 수수료를 부과합니다.
|
||||||
|
② “은행”은 매월 1일~말일까지 발생하는 수수료를 익월 10일(공휴일인 경우에는 익영업일)에 “이용기관”에게 부과하며, “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금하기로 합니다.
|
||||||
|
③ “은행”은 “이용기관”이 해지 등의 사유로 “서비스” 이용이 불가한 경우, 이용불가 시점까지 발생한 미결제 수수료를 본 “서비스” 해지일에 예금청구서 및 통장에 의하지 아니하고 “이용기관”의 수수료 납부계좌에서 출금하고, 부족액이 발생할 시에 별도 청구합니다.
|
||||||
|
④ “은행”은 제2항의 규정에도 불구하고 전산상의 이유 등으로 “이용기관”이 등록한 수수료 납부계좌에서 예금청구서 및 통장에 의하지 아니하고 대체 출금할 수 없는 경우, “이용기관”에 “은행”이 정하는 다른 방법으로 수수료 납부를 요청할 수 있습니다.
|
||||||
|
⑤ “이용기관”에게 부과되는 수수료는 “은행”이 정하는 바에 따릅니다. 다만 “은행”이 사업적으로 필요하다고 판단되는 경우 서비스 이용에 따른 수수료를 감면할 수 있습니다.
|
||||||
|
⑥ 수수료는 은행의 정책에 따라 변경될 수 있으며, 은행이 수수료를 변경할 경우 전자금융거래 기본약관의 내용을 준용합니다.
|
||||||
|
|
||||||
|
제6장 보칙
|
||||||
|
|
||||||
|
제15조 (최종사용자에 대한 고지 의무)
|
||||||
|
① “이용기관”은 중요사항에 대해 “최종사용자”에게 통보하여야 합니다.
|
||||||
|
② “이용기관”은 “서비스” 이용 관련사항을 변경한 경우에 “최종사용자”에게 사전에 통지하여야 합니다.
|
||||||
|
③ “은행”은 “이용기관”이 “최종사용자”에게 고지할 필요가 있다고 판단되는 사항을 고지하도록 “이용기관”에게 요구할 수 있습니다.
|
||||||
|
④ “이용기관”은 “시스템”과 관련하여 알게 된 “최종사용자” 정보가 제3자에게 유출되었을 경우 즉시 해당 “최종사용자” 및 “은행”에 통지하여 추가적인 피해가 없도록 조치하여야 합니다.
|
||||||
|
|
||||||
|
제16조 (부정 접속 방지)
|
||||||
|
① “은행”은 부정접속을 방지하기 위하여 “이용기관”의 “포털” 접속용 패스워드 입력오류가 연속 5회를 초과할 경우 업무 이용을 중지시킵니다.
|
||||||
|
② 제1항에 따라 업무 이용이 중지된 “이용기관”은 업무 이용을 위하여 “은행”에 해제 요청을 하여야 합니다.
|
||||||
|
|
||||||
|
제17조 (전산처리 절차)
|
||||||
|
전산처리명세의 송·수신 절차, 방법, 시간, 이체일 등 전산처리와 관련된 사항은 “포털”을 통해 별도 공시합니다.
|
||||||
|
|
||||||
|
제18조 (보안관리)
|
||||||
|
① “이용기관”은 “은행”에서 제시하는 보안 기준을 준수하여야 하고, “은행”은 “이용기관”의 전산시스템에 대하여 각 호의 사항을 점검 요청할 수 있습니다.
|
||||||
|
1. 개인정보 및 금융정보 암호화 운영현황
|
||||||
|
2. 해킹 등에 대비한 정책사항
|
||||||
|
3. 시스템 운영에 대한 정보보호 체계 등
|
||||||
|
② “이용기관”은 “서비스” 이용과 관련하여 감독 당국에서 관련 자료 또는 감독을 요청하면 이에 대하여 적극적으로 협조하여야 합니다.
|
||||||
|
③ “이용기관”의 “서비스” 이용에 대한 고객 정보 관리책임은 “이용기관”에게 있으며, “이용기관”의 전산시스템에 대한 해킹 또는 외부의 네트워크 공격 등 위험이 발생한 경우에는 즉시 “은행”에 이를 통지하고, 본 “서비스”의 이용을 중단하여야 합니다.
|
||||||
|
|
||||||
|
제19조 (기밀유지)
|
||||||
|
① “은행”과 “이용기관”은 “서비스”와 관련되거나 상호정보교환 등 업무처리 과정에서 인지 또는 획득한 정보를 이 업무처리 이외의 목적으로 사용하거나 외부에 유출하여서는 안 됩니다.
|
||||||
|
② 제1항에서 정한 의무를 위반하여 상대방 또는 제3자에게 손해가 발생한 경우, 귀책사유 있는 당사자는 상대방 또는 제3자에게 이로 인하여 발생한 손해의 일부 또는 전부를 배상하여야 합니다.
|
||||||
|
|
||||||
|
제20조 (손해배상 및 면책)
|
||||||
|
① “은행”은 “이용기관”으로부터 접근매체의 분실이나 도난의 통지를 받은 후에 제3자가 그 접근매체를 사용하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
|
||||||
|
② “은행”은 다음 각 1호의 사고로 인하여 “이용기관”에게 손해가 발생한 경우 그 손해를 배상합니다.
|
||||||
|
1. 접근매체의 위조나 변조로 발생한 사고
|
||||||
|
2. 계약체결 또는 거래지시의 전자적 전송이나 처리과정에서 발생한 사고
|
||||||
|
3. 전자금융거래를 위한 전자적 장치 또는 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조 제1항 제1호에 따른 “은행”의 정보통신망에 침입하여 거짓이나 그 밖의 부정한 방법으로 획득한 접근매체의 이용으로 발생한 사고
|
||||||
|
③ 제1항 및 제2항에 의하여 금전적 손해가 발생한 경우 해당 금액 및 이에 대한 1년 만기 정기예금 이율로 계산한 경과이자를 배상합니다. 다만, 손해액이 해당 금액과 1년 만기 정기예금 이율로 계산한 금액을 초과하는 경우에는 실손해액을 배상합니다.
|
||||||
|
④ 제2항의 규정에도 불구하고 “은행”은 “이용기관”이 고의 또는 과실로 다음 각 호의 행위를 하여 “이용기관”에게 손해가 생기는 경우 “은행”의 고의 또는 과실이 없다면 “은행”은 그 책임의 전부 또는 일부를 지지 않습니다.
|
||||||
|
1. “이용기관”이 접근매체를 제3자에게 대여하거나 사용을 위임한 경우 또는 양도나 담보의 목적으로 제공한 경우(「전자금융거래법」제18조에 따라 선불전자지급수단이나 전자화폐를 양도하거나 담보로 제공한 경우를 제외합니다.)
|
||||||
|
2. 제3자가 권한 없이 “이용기관”의 접근매체를 이용하여 전자금융거래를 할 수 있음을 알았거나 쉽게 알 수 있었음에도 불구하고 “이용기관”이 자신의 접근매체를 누설 또는 노출하거나 방치한 경우
|
||||||
|
3. “은행”이 접근매체를 통하여 “이용기관”의 신원, 권한 및 거래지시의 내용 등을 확인하는 것 외에 보안강화를 위하여 전자금융거래 시 사전에 요구하는 추가적인 보안조치를 “이용기관”이 정당한 사유 없이 거부하여 사고가 발생한 경우
|
||||||
|
4. “이용기관”이 제3호에 따른 추가적인 보안조치에 사용되는 매체·수단 또는 정보에 대하여 다음 각 목의 어느 하나에 해당하는 행위를 하여 사고가 발생한 경우
|
||||||
|
가. 누설·노출 또는 방치한 행위
|
||||||
|
나. 제3자에게 대여하거나 그 사용을 위임한 행위 또는 양도나 담보의 목적으로 제공한 행위
|
||||||
|
5. 법인(「중소기업기본법」제2조 제2항에 의한 소기업을 제외합니다.)인 “이용기관”에게 손해가 발생한 경우로 “은행”이 사고를 방지하기 위하여 보안절차를 수립하고 이를 철저히 준수하는 등 합리적으로 요구되는 충분한 주의의무를 다한 경우
|
||||||
|
|
||||||
|
제21조 (이의제기 및 협조)
|
||||||
|
① “이용기관”은 전자금융거래의 처리에 관하여 이의가 있을 때에는 “은행”의 분쟁처리기구에 그 해결을 요구하거나 금융감독원 금융분쟁조정위원회, 한국소비자보호원 소비자분쟁조정위원회 등을 통하여 분쟁조정을 신청할 수 있습니다.
|
||||||
|
② “이용기관”이 “은행”의 본점이나 영업점 또는 “은행”의 분쟁처리기구에 이의를 제기한 경우 “은행”은 15일 이내에 이에 대한 조사 또는 처리결과를 “이용기관”에게 알려야 합니다.
|
||||||
|
③ “은행”은 손해배상 등 분쟁처리를 위한 분쟁처리책임자 및 담당자를 지정하고, 그 연락처를 관련 인터넷 홈페이지에 게시하여야 합니다.
|
||||||
|
④ “이용기관”은 제20조 제2항에 정한 사고발생으로 인한 손해배상 등 분쟁처리와 관련한 “은행”의 사고조사 및 관계당국의 수사 또는 조사 절차에 협조하여야 합니다.
|
||||||
|
|
||||||
|
제22조 (거래기록·자료의 제공)
|
||||||
|
① “은행”은 “이용기관”의 요청이 있을 경우 「금융실명거래 및 비밀보장에 관한 법률」 등 다른 법률에 저촉되지 않는 범위 내에서 “은행”이 보존·관리하고 있는 전자금융거래 관련 기록·자료를 “이용기관”에게 제공하여야 합니다.
|
||||||
|
② “이용기관”이 제공 요청을 할 수 있는 거래기록·자료의 범위와 대상기간은 「전자금융거래법」 제22조 제1항 및 제2항에서 정한 기록 및 기간으로 합니다.
|
||||||
|
③ “이용기관”이 서면(전자문서를 제외합니다. 이하 같습니다.) 형태의 거래기록·자료(이하 “거래명세서”라 합니다.)를 제공할 것을 “은행”에 요청하는 경우에는 본인이 서면으로 영업점에 신청하여야 하며, “은행”은 신청 가능 영업점의 주소 및 전화번호 등을 인터넷 등을 통하여 “이용기관”이 알 수 있도록 게시하여야 합니다.
|
||||||
|
④ “은행”은 “이용기관”으로부터 제3항에 따른 거래명세서의 제공요청을 받은 경우 2주일 이내에 “이용기관”에게 거래명세서를 교부하여야 합니다.
|
||||||
|
⑤ “은행”은 불가피한 사유로 인하여 거래기록·자료(거래명세서 포함)의 제공이 불가능한 경우에는 그 사유를 “이용기관”에게 즉시 통보하여야 하며, 해당 사유가 소멸한 경우 다음 각 호에 정한 기한 내에 “이용기관”에게 확인 및 제공하여야 합니다.
|
||||||
|
1. 전자적 장치를 통한 제공의 경우에는 즉시
|
||||||
|
2. 제3항에 따른 서면 제공 방식의 경우에는 2주일 이내에 거래명세서를 교부
|
||||||
|
|
||||||
|
부칙
|
||||||
|
본 약관은 2026. 99. 99.부터 시행합니다.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
editor-content.css 대응 프래그먼트: <!doctype>/<html>/<head>/<style> 없이 순수 태그(h1/h2/h3/p/ul/li/strong)만 사용.
|
||||||
|
admin Summernote 코드뷰에 그대로 붙여넣으면 됨. class/style 속성 없음 -> 붙여넣기(paste-cleaner)든
|
||||||
|
코드뷰 직접 입력이든 태그 정리 과정에서 잘려나갈 것이 없다.
|
||||||
|
-->
|
||||||
|
<h1>알림수신동의서</h1>
|
||||||
|
<p><strong>(주)제주은행 귀중</strong></p>
|
||||||
|
<p>귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.</p>
|
||||||
|
<h2>발송정보</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>수집·이용 항목:</strong> 휴대폰 번호</li>
|
||||||
|
<li><strong>발송 목적:</strong> Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송</li>
|
||||||
|
<li><strong>보유·이용 기간:</strong> 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기</li>
|
||||||
|
</ul>
|
||||||
|
<p>□ 동의하지 않음 □ 동의함</p>
|
||||||
|
<p>※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.</p>
|
||||||
|
<p>※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.</p>
|
||||||
|
<p>※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.</p>
|
||||||
|
<h3>위 알림 수신에 동의하십니까?</h3>
|
||||||
|
<p>제정일자: 2026. 99. 99. 제정</p>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
알림수신동의서
|
||||||
|
|
||||||
|
(주)제주은행 귀중
|
||||||
|
|
||||||
|
귀 행의 Open API 포털에서 이용기관(귀 행과 계약을 체결한 법인) 관리자가 개발자 초대를 위하여 입력한 휴대폰 번호로 초대 안내 문자메시지(알림)를 발송하고자 하는 경우에는 「개인정보보호법」 등 관계 법령에 따라 수신자 본인의 동의가 필요합니다.
|
||||||
|
|
||||||
|
발송정보
|
||||||
|
ㄴ 수집·이용 항목: 휴대폰 번호
|
||||||
|
ㄴ 발송 목적: Open API 포털 개발자 초대 안내(초대 이용기관명, 초대자명, 인증번호, 가입 진행 링크 포함) 문자메시지 발송
|
||||||
|
ㄴ 보유·이용 기간: 초대 유효기간(발송일로부터 7일) 경과 또는 초대 처리(가입 완료·취소) 시까지 보유 후 파기
|
||||||
|
|
||||||
|
□ 동의하지 않음 □ 동의함
|
||||||
|
|
||||||
|
※ 이용기관 관리자는 본인이 입력한 휴대폰 번호의 실제 사용자로부터 위 알림 수신에 대한 동의를 사전에 받은 경우에 한하여 초대를 진행하여야 합니다.
|
||||||
|
|
||||||
|
※ 동의하지 않을 경우 초대 신청(등록)은 유지되나 초대 안내 문자메시지는 발송되지 않으며, 이 경우 이용기관 관리자로부터 별도의 방법으로 초대 안내를 받아야 합니다.
|
||||||
|
|
||||||
|
※ 수집된 휴대폰 번호는 문자메시지 발송 업무를 위탁받은 발송대행사에 위 목적 범위 내에서만 제공되며, 목적 외 용도로 이용되지 않습니다.
|
||||||
|
|
||||||
|
위 알림 수신에 동의하십니까?
|
||||||
|
|
||||||
|
제정일자: 2026. 99. 99. 제정
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
|
||||||
|
// Read-only dependency resolution; does not override versions or the normal build.
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
def portal = gradle.rootProject
|
||||||
|
portal.tasks.register('thymeleafCompatibilityDependencies') {
|
||||||
|
doLast {
|
||||||
|
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
|
||||||
|
output.mkdirs()
|
||||||
|
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
|
||||||
|
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
|
||||||
|
def rows = artifacts.findAll {
|
||||||
|
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||||
|
}.collect {
|
||||||
|
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
|
||||||
|
}.sort()
|
||||||
|
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
println "Dependency evidence: ${output}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_CHANGES = {
|
||||||
|
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"ognl:ognl": ("3.3.4", "3.3.5"),
|
||||||
|
}
|
||||||
|
EXPECTED_CLASSES = {
|
||||||
|
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
|
||||||
|
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
|
||||||
|
"ognl/Ognl.class": "ognl-3.3.5.jar",
|
||||||
|
}
|
||||||
|
LOCAL_PREFIXES = (
|
||||||
|
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
|
||||||
|
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies(path):
|
||||||
|
result = {}
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
module, version, filename = line.split("\t")
|
||||||
|
entries = result.setdefault(module, [])
|
||||||
|
require((version, filename) not in entries, "Duplicate artifact: " + filename)
|
||||||
|
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
|
||||||
|
entries.append((version, filename))
|
||||||
|
for entries in result.values():
|
||||||
|
entries.sort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compare(before, after):
|
||||||
|
old, new = dependencies(before), dependencies(after)
|
||||||
|
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
|
||||||
|
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
|
||||||
|
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
|
||||||
|
return {"external_modules": len(new), "changes": changed}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_war(path):
|
||||||
|
owners = {name: [] for name in EXPECTED_CLASSES}
|
||||||
|
with zipfile.ZipFile(path) as war:
|
||||||
|
names = war.namelist()
|
||||||
|
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
|
||||||
|
jars = sorted(name for name in names if name.endswith(".jar"))
|
||||||
|
basenames = [Path(name).name for name in jars]
|
||||||
|
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
|
||||||
|
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
|
||||||
|
for name in names), "Local profile packaged: " + str(path))
|
||||||
|
for name in jars:
|
||||||
|
basename = Path(name).name
|
||||||
|
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
|
||||||
|
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
|
||||||
|
classes = set(jar.namelist())
|
||||||
|
require(not any(c.startswith("jakarta/servlet/") for c in classes),
|
||||||
|
"Jakarta Servlet classes packaged: " + name)
|
||||||
|
for target in owners:
|
||||||
|
if target in classes:
|
||||||
|
owners[target].append(name)
|
||||||
|
# Java 8 compatibility of each upgraded library's entry class.
|
||||||
|
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
|
||||||
|
"Java >8 class: " + name + "!" + target)
|
||||||
|
for target, expected in EXPECTED_CLASSES.items():
|
||||||
|
require(owners[target] == ["WEB-INF/lib/" + expected],
|
||||||
|
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
|
||||||
|
for pattern, expected in [
|
||||||
|
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
|
||||||
|
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
|
||||||
|
(r"ognl-", "ognl-3.3.5.jar"),
|
||||||
|
]:
|
||||||
|
require([n for n in basenames if re.match(pattern, n)] == [expected],
|
||||||
|
"Wrong/duplicate library version for " + expected)
|
||||||
|
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"jar_count": len(jars), "class_providers": owners, "jars": jars}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
|
||||||
|
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
|
||||||
|
parser.add_argument("--war", type=Path, action="append", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
|
||||||
|
result = {
|
||||||
|
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
|
||||||
|
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
|
||||||
|
"wars": [inspect_war(path) for path in args.war],
|
||||||
|
}
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
print("PASS: dependency changes limited to three modules; both WARs verified")
|
||||||
|
for war in result["wars"]:
|
||||||
|
print(war["file"], war["sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Generated
+542
@@ -9,9 +9,454 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
@@ -322,6 +767,22 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -349,6 +810,48 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
@@ -389,6 +892,16 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/node-forge": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
@@ -403,6 +916,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
|||||||
+7
-2
@@ -1,15 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "eapim-portal",
|
"name": "eapim-portal",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "SASS build system for EAPIM Portal",
|
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
|
||||||
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||||
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
||||||
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||||
"build": "npm run sass:build && npm run sass:build:minified",
|
"build": "npm run sass:build && npm run sass:build:minified",
|
||||||
"dev": "npm run sass:watch"
|
"dev": "npm run sass:watch",
|
||||||
|
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
+6
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
|
|||||||
*/
|
*/
|
||||||
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
|
||||||
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다건 org 소속 CLIENTID 목록 (인덱스 페이지 전체 통계 집계용).
|
||||||
|
*/
|
||||||
|
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId IN :orgIds")
|
||||||
|
List<String> findClientIdsByOrgIdIn(@Param("orgIds") List<String> orgIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,36 @@ import java.sql.SQLException;
|
|||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||||
|
|
||||||
|
import com.google.common.util.concurrent.Futures;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Readiness probe.
|
* Readiness probe.
|
||||||
*
|
*
|
||||||
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
|
* <p>Distinct from {@link HealthCheckController} (liveness — servlet alive?).
|
||||||
* This endpoint validates both EMS and Gateway datasources via JDBC
|
* This endpoint validates both EMS and Gateway datasources via JDBC
|
||||||
* {@code Connection.isValid(timeout)} to confirm the app is ready to serve
|
* {@code Connection.isValid(timeout)}, plus three libraries that WebLogic's
|
||||||
* requests that depend on the database.
|
* oracle_common bundles at a different version from this app (see weblogic.xml
|
||||||
|
* {@code prefer-application-packages}): Thymeleaf's {@link SpringTemplateEngine}
|
||||||
|
* (jackson), jboss-logging, and Guava.
|
||||||
|
*
|
||||||
|
* <p>These exist because this controller bypasses view resolution entirely
|
||||||
|
* (plain {@code @RestController} JSON) — a classpath split between the app's
|
||||||
|
* copy and WebLogic's bundled copy of a preferred package can throw
|
||||||
|
* {@link LinkageError}/{@link java.util.ServiceConfigurationError} only on the
|
||||||
|
* real code paths that touch it, while this probe kept reporting 200. That
|
||||||
|
* already happened once for jackson (Thymeleaf's dialect/module discovery
|
||||||
|
* failed on every real page while /health/ready stayed green) — jboss-logging
|
||||||
|
* and Guava carry the same risk (confirmed version mismatch against
|
||||||
|
* oracle_common, not yet observed failing in production) so they get the same
|
||||||
|
* kind of forced-touch check here.
|
||||||
*
|
*
|
||||||
* <p>HTTP 200 + JSON when all checks pass.
|
* <p>HTTP 200 + JSON when all checks pass.
|
||||||
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
|
* <p>HTTP 503 + JSON when any check fails — body still includes the per-component
|
||||||
@@ -30,24 +47,34 @@ public class ReadinessController {
|
|||||||
|
|
||||||
private final DataSource portalDataSource;
|
private final DataSource portalDataSource;
|
||||||
private final DataSource gatewayDataSource;
|
private final DataSource gatewayDataSource;
|
||||||
|
private final SpringTemplateEngine templateEngine;
|
||||||
|
|
||||||
public ReadinessController(
|
public ReadinessController(
|
||||||
@Qualifier("portalDataSource") DataSource portalDataSource,
|
@Qualifier("portalDataSource") DataSource portalDataSource,
|
||||||
@Qualifier("gatewayDataSource") DataSource gatewayDataSource) {
|
@Qualifier("gatewayDataSource") DataSource gatewayDataSource,
|
||||||
|
SpringTemplateEngine templateEngine) {
|
||||||
this.portalDataSource = portalDataSource;
|
this.portalDataSource = portalDataSource;
|
||||||
this.gatewayDataSource = gatewayDataSource;
|
this.gatewayDataSource = gatewayDataSource;
|
||||||
|
this.templateEngine = templateEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/health/ready", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<String> ready() {
|
public ResponseEntity<String> ready() {
|
||||||
String ems = check(portalDataSource);
|
String ems = check(portalDataSource);
|
||||||
String gw = check(gatewayDataSource);
|
String gw = check(gatewayDataSource);
|
||||||
boolean ok = "UP".equals(ems) && "UP".equals(gw);
|
String view = checkTemplateEngine();
|
||||||
|
String logging = checkJbossLogging();
|
||||||
|
String guava = checkGuava();
|
||||||
|
boolean ok = "UP".equals(ems) && "UP".equals(gw) && "UP".equals(view)
|
||||||
|
&& "UP".equals(logging) && "UP".equals(guava);
|
||||||
|
|
||||||
String body = "{"
|
String body = "{"
|
||||||
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
+ "\"status\":\"" + (ok ? "UP" : "DOWN") + "\","
|
||||||
+ "\"ems\":\"" + ems + "\","
|
+ "\"ems\":\"" + ems + "\","
|
||||||
+ "\"gateway\":\""+ gw + "\""
|
+ "\"gateway\":\"" + gw + "\","
|
||||||
|
+ "\"view\":\"" + view + "\","
|
||||||
|
+ "\"logging\":\"" + logging + "\","
|
||||||
|
+ "\"guava\":\"" + guava + "\""
|
||||||
+ "}";
|
+ "}";
|
||||||
|
|
||||||
return ResponseEntity.status(ok ? 200 : 503)
|
return ResponseEntity.status(ok ? 200 : 503)
|
||||||
@@ -55,6 +82,49 @@ public class ReadinessController {
|
|||||||
.body(body);
|
.body(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@code getConfiguration()} lazily runs Thymeleaf's dialect/module init on first call
|
||||||
|
* and caches it — so this is cheap once healthy, and reproduces the exact failure path
|
||||||
|
* a real page render would hit. Catches {@link Throwable}: init failures here have
|
||||||
|
* surfaced as {@link Error} (ServiceConfigurationError), not just Exception. */
|
||||||
|
private String checkTemplateEngine() {
|
||||||
|
try {
|
||||||
|
templateEngine.getConfiguration();
|
||||||
|
return "UP";
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return down(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forces jboss-logging's {@code Logger} + its bound provider (LogManager/JBoss/JUL
|
||||||
|
* discovery) to load and actually run a log-level check, not just resolve the class. */
|
||||||
|
private static String checkJbossLogging() {
|
||||||
|
try {
|
||||||
|
Logger log = Logger.getLogger(ReadinessController.class);
|
||||||
|
log.isDebugEnabled();
|
||||||
|
return "UP";
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return down(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code Futures.immediateFuture} touches Guava's {@code util.concurrent} machinery
|
||||||
|
* (AbstractFuture/InternalFutureFailureAccess) — the part backed by the separate
|
||||||
|
* {@code failureaccess} jar, which is exactly where an app/WebLogic version mismatch
|
||||||
|
* would split across classloaders. */
|
||||||
|
private static String checkGuava() {
|
||||||
|
try {
|
||||||
|
Futures.immediateFuture(Boolean.TRUE).isDone();
|
||||||
|
return "UP";
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return down(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String down(Throwable t) {
|
||||||
|
String msg = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
|
||||||
|
return "DOWN:" + msg.replace('"', '\'').replace('\n', ' ').replace('\r', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
private static String check(DataSource ds) {
|
private static String check(DataSource ds) {
|
||||||
if (ds == null) return "DOWN:NO_DATASOURCE";
|
if (ds == null) return "DOWN:NO_DATASOURCE";
|
||||||
try (Connection c = ds.getConnection()) {
|
try (Connection c = ds.getConnection()) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
|
|||||||
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||||
|
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
@@ -15,6 +16,11 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.web.PageableDefault;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.ui.ModelMap;
|
import org.springframework.ui.ModelMap;
|
||||||
@@ -33,6 +39,7 @@ public class ApiController {
|
|||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
private final ApiSearchFacade apiSearchFacade;
|
private final ApiSearchFacade apiSearchFacade;
|
||||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||||
|
private final ApiListProperties apiListProperties;
|
||||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||||
|
|
||||||
@@ -79,12 +86,18 @@ public class ApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) {
|
public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
|
||||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
|
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
||||||
|
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
|
||||||
|
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
|
||||||
|
|
||||||
model.addAttribute("search", search);
|
model.addAttribute("search", search);
|
||||||
model.addAttribute("services", searchResult.get("services"));
|
model.addAttribute("services", searchResult.get("services"));
|
||||||
model.addAttribute("apis", searchResult.get("apis"));
|
model.addAttribute("apis", apiPage.getContent());
|
||||||
|
model.addAttribute("page", apiPage);
|
||||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||||
@@ -94,6 +107,19 @@ public class ApiController {
|
|||||||
return "apps/apis/mainApiList";
|
return "apps/apis/mainApiList";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
|
||||||
|
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
|
||||||
|
*/
|
||||||
|
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||||
|
int total = apis == null ? 0 : apis.size();
|
||||||
|
int fromIndex = (int) Math.min(pageable.getOffset(), total);
|
||||||
|
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
|
||||||
|
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
||||||
|
|
||||||
|
return new PageImpl<>(content, pageable, total);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/testbed/api")
|
@GetMapping("/testbed/api")
|
||||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||||
|
|||||||
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
|
|||||||
private String displayRoleCode;
|
private String displayRoleCode;
|
||||||
|
|
||||||
private String apiGroupName;
|
private String apiGroupName;
|
||||||
|
|
||||||
|
private String apiGroupId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.eactive.apim.portal.apps.apis.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** OPEN API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 공통 페이지 크기. */
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ApiListProperties {
|
||||||
|
|
||||||
|
private static final String PAGE_SIZE_PROPERTY = "api.list.page-size";
|
||||||
|
private static final int DEFAULT_PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** PTL_PROPERTY(Portal / api.list.page-size)를 조회하고, 없으면 기본값으로 생성한다. */
|
||||||
|
public int getPageSize() {
|
||||||
|
String value = portalPropertyService.getOrCreateProperty(
|
||||||
|
"Portal", PAGE_SIZE_PROPERTY, String.valueOf(DEFAULT_PAGE_SIZE),
|
||||||
|
"API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 페이지당 노출 개수 (양의 정수, 기본 15)");
|
||||||
|
try {
|
||||||
|
int size = Integer.parseInt(value == null ? "" : value.trim());
|
||||||
|
if (size > 0) {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// 비어 있거나 정수가 아닌 설정은 기본값을 사용한다.
|
||||||
|
}
|
||||||
|
log.warn("{} 값이 올바르지 않음('{}') - 기본값 {} 사용", PAGE_SIZE_PROPERTY, value, DEFAULT_PAGE_SIZE);
|
||||||
|
return DEFAULT_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
|
|||||||
filteredApis.forEach(api -> {
|
filteredApis.forEach(api -> {
|
||||||
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
|
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
|
||||||
if (service != null) {
|
if (service != null) {
|
||||||
api.setMainIcon(service.getMainIcon());
|
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
|
||||||
|
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
|
||||||
|
api.setApiGroupId(service.getId());
|
||||||
api.setApiGroupName(service.getGroupName());
|
api.setApiGroupName(service.getGroupName());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+37
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
|||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
|
||||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ApiServiceController {
|
public class ApiServiceController {
|
||||||
|
|
||||||
|
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
|
||||||
|
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
|
||||||
|
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
|
||||||
|
|
||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
|
|
||||||
|
|
||||||
@@ -52,4 +65,28 @@ public class ApiServiceController {
|
|||||||
|
|
||||||
return "apps/apiservice/apiServiceDetail";
|
return "apps/apiservice/apiServiceDetail";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
|
||||||
|
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
|
||||||
|
* 브라우저 캐시로 재사용시킨다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/icon")
|
||||||
|
public ResponseEntity<byte[]> icon(@PathVariable String id) {
|
||||||
|
String mainIcon = apiServiceService.getMainIcon(id);
|
||||||
|
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
|
||||||
|
|
||||||
|
if (matcher == null || !matcher.matches()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FOUND)
|
||||||
|
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(matcher.group(1)))
|
||||||
|
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
|
||||||
|
.body(imageBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,5 +120,14 @@ public class ApiServiceService {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
|
||||||
|
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
|
||||||
|
*/
|
||||||
|
public String getMainIcon(String id) {
|
||||||
|
return apiServiceRepository.findById(id)
|
||||||
|
.map(ApiGroup::getMainIcon)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
package com.eactive.apim.portal.apps.app.service;
|
package com.eactive.apim.portal.apps.app.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||||
*
|
*
|
||||||
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
|
||||||
|
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
|
||||||
|
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
|
||||||
|
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
|
|||||||
private static final String PROP_GROUP = "Portal";
|
private static final String PROP_GROUP = "Portal";
|
||||||
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||||
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||||
|
private static final String PROP_ADMIN_BASE_URL_DESC =
|
||||||
|
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
|
||||||
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
|
|||||||
/**
|
/**
|
||||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||||
*
|
*
|
||||||
|
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
|
||||||
|
*
|
||||||
* @param clientId 차단할 클라이언트 ID
|
* @param clientId 차단할 클라이언트 ID
|
||||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||||
*/
|
*/
|
||||||
public void blockClient(String clientId) {
|
public void blockClient(String clientId) {
|
||||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
List<String> baseUrls = resolveBaseUrls();
|
||||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
|
||||||
|
|
||||||
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
RestClientException lastError = null;
|
||||||
|
for (int i = 0; i < baseUrls.size(); i++) {
|
||||||
|
String baseUrl = baseUrls.get(i);
|
||||||
|
String url = baseUrl + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
ResponseEntity<Map> response;
|
||||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
try {
|
||||||
|
response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
// 통신 계층 실패 — 다음 admin 주소로 failover
|
||||||
|
lastError = e;
|
||||||
|
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
|
||||||
|
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
Map<?, ?> body = response.getBody();
|
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
|
||||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
Map<?, ?> body = response.getBody();
|
||||||
if (!success) {
|
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
if (!success) {
|
||||||
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
|
||||||
|
*
|
||||||
|
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
|
||||||
|
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
|
||||||
|
*/
|
||||||
|
private List<String> resolveBaseUrls() {
|
||||||
|
String raw = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
|
||||||
|
|
||||||
|
List<String> urls = parseBaseUrls(raw);
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
|
||||||
|
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
|
||||||
|
*/
|
||||||
|
static List<String> parseBaseUrls(String raw) {
|
||||||
|
Set<String> ordered = new LinkedHashSet<>();
|
||||||
|
if (raw != null) {
|
||||||
|
for (String token : raw.split("[,\\r\\n]")) {
|
||||||
|
String url = stripTrailingSlashes(token.trim());
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
ordered.add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ public class ApprovalService {
|
|||||||
approval.setApprovalType(ApprovalType.APP);
|
approval.setApprovalType(ApprovalType.APP);
|
||||||
approval.setTargetId(request.getId());
|
approval.setTargetId(request.getId());
|
||||||
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
|
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
|
||||||
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인");
|
// 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다.
|
||||||
|
// 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다.
|
||||||
|
String clientName = request.getClientName();
|
||||||
|
String clientNamePart = clientName == null || clientName.trim().isEmpty()
|
||||||
|
? "" : " [" + clientName.trim() + "]";
|
||||||
|
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart
|
||||||
|
+ " " + request.getType().getDescription() + " 승인");
|
||||||
|
|
||||||
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
|
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
|
||||||
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
|
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
|
||||||
|
|||||||
@@ -64,33 +64,36 @@ public class ApiPermissionFilter {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
|
/**
|
||||||
|
* org 축과 role 축은 각자 독립적으로 선택적 제한이다(비어있으면 그 축은 통과) — 둘을 AND로 합친다.
|
||||||
|
* (과거 OR 합성은 한쪽 축이 비어있으면 그 축이 무조건 true가 되어 다른 쪽 제한을 무력화시키는
|
||||||
|
* 결함이 있었다 — 예: displayOrg 만 설정하고 displayRoleCode 를 비워두면 roleMatch 가 로그인
|
||||||
|
* 여부만으로 true 가 되어 조직 제한이 사실상 적용되지 않았다.)
|
||||||
|
*/
|
||||||
|
static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
|
||||||
String org, String roleCode) {
|
String org, String roleCode) {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
|
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
|
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|
||||||
spec.getDisplayOrg().isEmpty() ||
|
|| (org != null && spec.getDisplayOrg().contains(org));
|
||||||
spec.getDisplayOrg().contains(org));
|
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|
||||||
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
|
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
|
||||||
spec.getDisplayRoleCode().isEmpty() ||
|
return orgOk && roleOk;
|
||||||
spec.getDisplayRoleCode().contains(roleCode));
|
|
||||||
return orgMatch || roleMatch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
|
/** {@link #checkServicePermissions} 와 동일 규칙(중복 유지 — 대상 DTO 타입만 다름). */
|
||||||
|
static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
|
||||||
String org, String roleCode) {
|
String org, String roleCode) {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
|
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
|
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|
||||||
spec.getDisplayOrg().isEmpty() ||
|
|| (org != null && spec.getDisplayOrg().contains(org));
|
||||||
spec.getDisplayOrg().contains(org));
|
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|
||||||
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
|
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
|
||||||
spec.getDisplayRoleCode().isEmpty() ||
|
return orgOk && roleOk;
|
||||||
spec.getDisplayRoleCode().contains(roleCode));
|
|
||||||
return orgMatch || roleMatch;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
|||||||
private void validateResendTime(String recipientKey) {
|
private void validateResendTime(String recipientKey) {
|
||||||
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
|
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
|
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
|
||||||
.plusSeconds(resendLimitSeconds).isAfter(now)) {
|
.minusSeconds(authNumberExpirationTime)
|
||||||
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
|
.plusSeconds(resendLimitSeconds);
|
||||||
|
if (resendAvailableAt.isAfter(now)) {
|
||||||
|
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
|
||||||
|
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
|
||||||
|
throw new AuthNumberException(
|
||||||
|
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
|
||||||
|
remainingSeconds));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -1,6 +1,8 @@
|
|||||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker.Outcome;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
@@ -11,6 +13,8 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +34,7 @@ public class StepUpPasswordController {
|
|||||||
|
|
||||||
private final UserFacade userFacade;
|
private final UserFacade userFacade;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
|
||||||
|
|
||||||
@GetMapping("/password")
|
@GetMapping("/password")
|
||||||
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||||
@@ -44,7 +49,8 @@ public class StepUpPasswordController {
|
|||||||
@PostMapping("/password")
|
@PostMapping("/password")
|
||||||
public String verify(@RequestParam String currentPassword,
|
public String verify(@RequestParam String currentPassword,
|
||||||
@RequestParam(required = false) String returnUrl,
|
@RequestParam(required = false) String returnUrl,
|
||||||
HttpSession session, Model model) {
|
HttpSession session, HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) {
|
||||||
String path = pathOf(returnUrl);
|
String path = pathOf(returnUrl);
|
||||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
@@ -52,12 +58,21 @@ public class StepUpPasswordController {
|
|||||||
|
|
||||||
String loginId = SecurityUtil.getCurrentLoginId();
|
String loginId = SecurityUtil.getCurrentLoginId();
|
||||||
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||||
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||||
|
passwordConfirmFailureTracker.reset(session);
|
||||||
twoFactorService.grantStepUpPass(session, path);
|
twoFactorService.grantStepUpPass(session, path);
|
||||||
return "redirect:" + path;
|
return "redirect:" + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
// 연속 실패 카운트 증가. 임계치 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다(무차별 대입 방어).
|
||||||
|
Outcome outcome = passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||||
|
if (outcome.isForcedLogout()) {
|
||||||
|
return outcome.isAccountLocked()
|
||||||
|
? "redirect:/login?pwFailExceeded=1&locked=1"
|
||||||
|
: "redirect:/login?pwFailExceeded=1";
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addAttribute("error", outcome.getMessage());
|
||||||
model.addAttribute("returnUrl", path);
|
model.addAttribute("returnUrl", path);
|
||||||
return "apps/auth/stepupPassword";
|
return "apps/auth/stepupPassword";
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-1
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.apps.community.partnership.controller;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO;
|
||||||
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade;
|
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade;
|
||||||
|
import com.eactive.apim.portal.common.security.WriteRateLimitService;
|
||||||
|
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -20,10 +22,13 @@ import javax.validation.Valid;
|
|||||||
public class PartnershipApplicationController {
|
public class PartnershipApplicationController {
|
||||||
|
|
||||||
private final PartnershipApplicationFacade partnershipApplicationFacade;
|
private final PartnershipApplicationFacade partnershipApplicationFacade;
|
||||||
|
private final WriteRateLimitService writeRateLimitService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade){
|
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade,
|
||||||
|
WriteRateLimitService writeRateLimitService){
|
||||||
this.partnershipApplicationFacade = partnershipApplicationFacade;
|
this.partnershipApplicationFacade = partnershipApplicationFacade;
|
||||||
|
this.writeRateLimitService = writeRateLimitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +57,13 @@ public class PartnershipApplicationController {
|
|||||||
return "apps/community/mainPartnershipForm";
|
return "apps/community/mainPartnershipForm";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
|
||||||
|
if (writeRateLimitService.isExceeded(WriteTarget.PARTNERSHIP)) {
|
||||||
|
redirectAttributes.addFlashAttribute("error",
|
||||||
|
writeRateLimitService.exceededMessage(WriteTarget.PARTNERSHIP));
|
||||||
|
return "redirect:/partnership";
|
||||||
|
}
|
||||||
|
|
||||||
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
||||||
|
|
||||||
// 성공 메시지 추가
|
// 성공 메시지 추가
|
||||||
@@ -61,4 +73,23 @@ public class PartnershipApplicationController {
|
|||||||
return "redirect:/partnership";
|
return "redirect:/partnership";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본인이 작성한 피드백/개선요청 1건 삭제.
|
||||||
|
* 목록(최근 3건)의 삭제 버튼이 항목별 form 을 POST 한다 — 등록과 동일하게 폼 전송 + flash 메시지 방식.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/delete")
|
||||||
|
public String deleteMyPartnershipApplication(@PathVariable String id, RedirectAttributes redirectAttributes) {
|
||||||
|
if (!SecurityUtil.isAuthenticated()) {
|
||||||
|
return "redirect:/login?reason=auth&redirect=/partnership";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
partnershipApplicationFacade.deleteMyApplication(id);
|
||||||
|
redirectAttributes.addFlashAttribute("success", "피드백/개선요청이 삭제되었습니다.");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/partnership";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -3,9 +3,12 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
|
|||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
@@ -16,4 +19,26 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
|
|||||||
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||||
*/
|
*/
|
||||||
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||||
|
* 등가 조회가 가능한 이유는 위와 동일하다.
|
||||||
|
*/
|
||||||
|
List<PartnershipApplication> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
|
||||||
|
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
|
||||||
|
*/
|
||||||
|
Optional<PartnershipApplication> findByIdAndCreatedBy(String id, String createdBy);
|
||||||
|
|
||||||
|
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
|
||||||
|
@Transactional
|
||||||
|
long deleteByCreatedBy(String createdBy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
|
||||||
|
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
|
||||||
|
*/
|
||||||
|
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
|
|||||||
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||||
*/
|
*/
|
||||||
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
|
||||||
|
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
|
||||||
|
*/
|
||||||
|
void deleteMyApplication(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -20,6 +20,7 @@ import java.util.Collections;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -74,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
|||||||
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
|
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
|
||||||
return partnershipApplicationMapper.toSummaryDtoList(recent);
|
return partnershipApplicationMapper.toSummaryDtoList(recent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteMyApplication(String id) {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
if (user == null) {
|
||||||
|
throw new IllegalArgumentException("로그인이 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// id 만으로 조회하지 않고 createdBy 를 함께 걸어 타인 글 삭제를 원천 차단한다.
|
||||||
|
PartnershipApplication target = partnershipApplicationService
|
||||||
|
.findOwnedByCreatedBy(id, user.getId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("삭제할 수 있는 피드백/개선요청이 아닙니다."));
|
||||||
|
|
||||||
|
// 첨부파일도 함께 정리한다(관리자 삭제 PortalPartnershipManService.delete 와 동일 처리).
|
||||||
|
if (StringUtils.isNotBlank(target.getFileId())) {
|
||||||
|
fileService.deleteFile(target.getFileId());
|
||||||
|
}
|
||||||
|
partnershipApplicationService.deletePartnershipApplication(target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
|
|||||||
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||||
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
|
|||||||
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
|
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
|
||||||
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
|
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성자 본인 글 단건 조회. id 와 createdBy 를 함께 조건으로 걸어 타인 글은 조회되지 않는다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<PartnershipApplication> findOwnedByCreatedBy(String id, String createdBy) {
|
||||||
|
return partnershipApplicationRepository.findByIdAndCreatedBy(id, createdBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deletePartnershipApplication(PartnershipApplication partnershipApplication) {
|
||||||
|
partnershipApplicationRepository.delete(partnershipApplication);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -3,6 +3,8 @@ package com.eactive.apim.portal.apps.community.qna.controller;
|
|||||||
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO;
|
||||||
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
||||||
import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
|
import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade;
|
||||||
|
import com.eactive.apim.portal.common.security.WriteRateLimitService;
|
||||||
|
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||||
@@ -37,11 +39,14 @@ public class InquiryController {
|
|||||||
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
|
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
|
||||||
private final InquiryFacade inquiryFacade;
|
private final InquiryFacade inquiryFacade;
|
||||||
private final InquiryCommentFacade inquiryCommentFacade;
|
private final InquiryCommentFacade inquiryCommentFacade;
|
||||||
|
private final WriteRateLimitService writeRateLimitService;
|
||||||
|
|
||||||
public InquiryController(InquiryFacade inquiryFacade,
|
public InquiryController(InquiryFacade inquiryFacade,
|
||||||
InquiryCommentFacade inquiryCommentFacade) {
|
InquiryCommentFacade inquiryCommentFacade,
|
||||||
|
WriteRateLimitService writeRateLimitService) {
|
||||||
this.inquiryFacade = inquiryFacade;
|
this.inquiryFacade = inquiryFacade;
|
||||||
this.inquiryCommentFacade = inquiryCommentFacade;
|
this.inquiryCommentFacade = inquiryCommentFacade;
|
||||||
|
this.writeRateLimitService = writeRateLimitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,6 +138,13 @@ public class InquiryController {
|
|||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
|
||||||
|
if (writeRateLimitService.isExceeded(WriteTarget.INQUIRY)) {
|
||||||
|
redirectAttributes.addFlashAttribute("error",
|
||||||
|
writeRateLimitService.exceededMessage(WriteTarget.INQUIRY));
|
||||||
|
return REDIRECT_INQUIRY;
|
||||||
|
}
|
||||||
|
|
||||||
inquiryFacade.createInquiry(inquiryDTO, image);
|
inquiryFacade.createInquiry(inquiryDTO, image);
|
||||||
|
|
||||||
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
||||||
|
|||||||
+15
@@ -3,14 +3,29 @@ package com.eactive.apim.portal.apps.community.qna.repository;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.qna.entity.Inquiry;
|
import com.eactive.apim.portal.qna.entity.Inquiry;
|
||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@EMSDataSource
|
@EMSDataSource
|
||||||
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
|
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
|
||||||
|
|
||||||
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
|
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
long deleteByInquirer_Id(String inquirerId);
|
||||||
|
|
||||||
|
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
|
||||||
|
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||||
|
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||||
|
*/
|
||||||
|
List<Inquiry> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryReposito
|
|||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||||
|
import com.eactive.apim.portal.user.entity.UserLog;
|
||||||
|
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -74,6 +76,7 @@ public class LoginFinalizer {
|
|||||||
private final PortalOrgRepository portalOrgRepository;
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
private final UserSessionService userSessionService;
|
private final UserSessionService userSessionService;
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final UserLogRepository userLogRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
|
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
|
||||||
@@ -96,11 +99,19 @@ public class LoginFinalizer {
|
|||||||
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
|
// 기존 세션이 끊기는 건인지) 기록한다. 현재 세션 등록(registerSession)은 이 뒤에 일어나므로
|
||||||
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
|
// 여기서 조회되는 활성 세션은 항상 "다른 곳"의 세션이다.
|
||||||
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
|
boolean duplicate = userSessionService.getActiveSession(normalizedUsername).isPresent();
|
||||||
|
// 화면 표시용 "직전 로그인" 조회 — 이번 로그인 기록(logSuccess) 삽입 전에 조회해야
|
||||||
|
// 방금 로그인이 아닌 그 이전 성공 로그인을 얻는다.
|
||||||
|
Optional<UserLog> previousLogin = userLogRepository
|
||||||
|
.findFirstByLoginIdAndSuccessOrderByLoginTimeDesc(rawUsername, true);
|
||||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
|
userLogService.logSuccess(rawUsername, ip, sessionId, loginType, request.getHeader("User-Agent"),
|
||||||
duplicate);
|
duplicate);
|
||||||
|
|
||||||
String contextPath = request.getContextPath();
|
String contextPath = request.getContextPath();
|
||||||
HttpSession session = request.getSession();
|
HttpSession session = request.getSession();
|
||||||
|
previousLogin.ifPresent(log -> {
|
||||||
|
session.setAttribute("previousLoginDate", log.getLoginTime());
|
||||||
|
session.setAttribute("previousLoginIp", log.getIp());
|
||||||
|
});
|
||||||
|
|
||||||
applyPostLoginState(user, session, rawUsername, contextPath);
|
applyPostLoginState(user, session, rawUsername, contextPath);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
|
|||||||
* 인덱스 페이지 하단 통계 DTO
|
* 인덱스 페이지 하단 통계 DTO
|
||||||
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
|
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
|
||||||
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
|
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
|
||||||
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
|
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
|
|||||||
private int activeAppCount;
|
private int activeAppCount;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 이용 건수
|
* API 이용 건수 (월누적)
|
||||||
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총 수
|
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
|
||||||
*/
|
*/
|
||||||
private int totalApiCount;
|
private int totalApiCount;
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-6
@@ -1,14 +1,20 @@
|
|||||||
package com.eactive.apim.portal.apps.main.service;
|
package com.eactive.apim.portal.apps.main.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.app.entity.Credential;
|
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
|
||||||
|
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
|
||||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||||
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
|
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
|
||||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
|
||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
|
|||||||
|
|
||||||
private final PortalOrgRepository portalOrgRepository;
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
private final CredentialRepository credentialRepository;
|
private final CredentialRepository credentialRepository;
|
||||||
|
private final GwAuthClientRepository gwAuthClientRepository;
|
||||||
|
private final ApiStatsDayRepository apiStatsDayRepository;
|
||||||
|
private final ApiStatsHourRepository apiStatsHourRepository;
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
// 캐시된 통계 데이터
|
// 캐시된 통계 데이터
|
||||||
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
|
|||||||
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
|
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
|
||||||
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
|
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
|
||||||
|
|
||||||
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API 수
|
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건수
|
||||||
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
|
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
|
||||||
totalApiCount = activeApps.stream()
|
.filter(id -> id != null && !id.trim().isEmpty())
|
||||||
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
|
.collect(Collectors.toList());
|
||||||
.sum();
|
if (!clientIds.isEmpty()) {
|
||||||
|
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedStatistics = IndexStatisticsDTO.builder()
|
cachedStatistics = IndexStatisticsDTO.builder()
|
||||||
@@ -125,6 +136,30 @@ public class IndexStatisticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게이트웨이 클라이언트들의 이번 달 1일~오늘 누적 API 호출 건수(TOTAL_CNT 합산).
|
||||||
|
* 어제까지는 API_STATS_DAY(일별 집계), 오늘은 아직 DAY 미집계이므로 API_STATS_HOUR로 합산한다.
|
||||||
|
* ({@link com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService#combineDayAndToday}와 동일 패턴)
|
||||||
|
*/
|
||||||
|
private long getMonthlyApiCallCount(List<String> clientIds) {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
LocalDate monthStart = today.withDayOfMonth(1);
|
||||||
|
LocalDate dayEnd = today.minusDays(1);
|
||||||
|
|
||||||
|
long total = 0L;
|
||||||
|
if (!monthStart.isAfter(dayEnd)) {
|
||||||
|
total += nz(apiStatsDayRepository.findSummary(clientIds, monthStart, dayEnd).getTotalCount());
|
||||||
|
}
|
||||||
|
ApiStatisticsSummaryDto todaySummary = apiStatsHourRepository.findSummary(
|
||||||
|
clientIds, today.atStartOfDay(), today.atTime(LocalTime.MAX));
|
||||||
|
total += nz(todaySummary.getTotalCount());
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long nz(Long value) {
|
||||||
|
return value != null ? value : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PortalProperty에서 통계 노출 여부 조회
|
* PortalProperty에서 통계 노출 여부 조회
|
||||||
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
|
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
|
||||||
|
|||||||
+8
-3
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||||
|
|
||||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
searchDto.setStartDate(today.minusDays(7));
|
||||||
searchDto.setEndDate(LocalDate.now());
|
searchDto.setEndDate(today);
|
||||||
searchDto.setClientId(null);
|
searchDto.setClientId(null);
|
||||||
|
|
||||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("details", result.getDetails());
|
model.addAttribute("details", result.getDetails());
|
||||||
model.addAttribute("periods", result.getPeriods());
|
model.addAttribute("periods", result.getPeriods());
|
||||||
model.addAttribute("searchDto", searchDto);
|
model.addAttribute("searchDto", searchDto);
|
||||||
|
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||||
|
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||||
|
model.addAttribute("statsMaxDate", today.toString());
|
||||||
|
|
||||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
|||||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||||
return searchDto.isMonthly()
|
return searchDto.isMonthly()
|
||||||
? "조회할 월이 올바르지 않습니다."
|
? "조회할 월이 올바르지 않습니다."
|
||||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||||
|
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private PortalOrg getPortalOrg() {
|
private PortalOrg getPortalOrg() {
|
||||||
|
|||||||
+9
-4
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
|||||||
public class ApiStatisticsSearchDto {
|
public class ApiStatisticsSearchDto {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최대 조회 가능 일수 (일별 모드).
|
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||||
*/
|
*/
|
||||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||||
|
|
||||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||||
*/
|
*/
|
||||||
public boolean isValidDateRange() {
|
public boolean isValidDateRange() {
|
||||||
|
return isValidDateRange(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isValidDateRange(LocalDate today) {
|
||||||
if (startDate == null || endDate == null) {
|
if (startDate == null || endDate == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (startDate.isAfter(endDate)) {
|
if (startDate.isAfter(endDate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
LocalDate earliestDate = today.minusYears(1);
|
||||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
|
||||||
|
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
|||||||
import com.eactive.apim.portal.apps.user.dto.*;
|
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.OrgRegisterFacade;
|
||||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
@@ -55,6 +56,7 @@ public class AccountController {
|
|||||||
private final UserSessionService userSessionService;
|
private final UserSessionService userSessionService;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
private final TwoFactorProperties twoFactorProperties;
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
|
||||||
|
|
||||||
|
|
||||||
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
||||||
@@ -65,12 +67,18 @@ public class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password/confirm")
|
@PostMapping("/password/confirm")
|
||||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword,
|
||||||
|
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||||
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
|
if (userFacade.verifyCurrentPassword(currentLoginId, inputPassword)) {
|
||||||
|
passwordConfirmFailureTracker.reset(session);
|
||||||
|
return ResponseEntity.ok(new ValidationResponse(true, "비밀번호가 확인되었습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
String message = isPasswordCorrect ? "비밀번호가 확인되었습니다." : "비밀번호가 일치하지 않습니다.";
|
// 무제한 시도 차단 — step-up 확인 페이지와 동일한 카운터/정책을 쓴다.
|
||||||
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
|
PasswordConfirmFailureTracker.Outcome outcome =
|
||||||
|
passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||||
|
return ResponseEntity.ok(new ValidationResponse(false, outcome.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/password/verify")
|
@GetMapping("/password/verify")
|
||||||
@@ -96,15 +104,29 @@ public class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password/verify")
|
@PostMapping("/password/verify")
|
||||||
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
|
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes,
|
||||||
|
HttpSession session, HttpServletRequest request, HttpServletResponse response, Model model) {
|
||||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||||
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
||||||
|
passwordConfirmFailureTracker.reset(session);
|
||||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||||
|
// GET /password/change 와 동일하게 2FA 필요 여부를 내려준다.
|
||||||
|
// 누락되면 폼 JS 가 제출 가로채기(비밀번호 규칙 게이트 + 2FA 팝업)를 등록하지 않아
|
||||||
|
// 서버가 통과권 없음으로 계속 되돌리고 비밀번호를 바꿀 수 없다.
|
||||||
|
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||||
return "apps/mypage/passwordChange";
|
return "apps/mypage/passwordChange";
|
||||||
} else {
|
|
||||||
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
|
||||||
return "redirect:/password/verify";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 무제한 시도 차단 — 임계 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다.
|
||||||
|
PasswordConfirmFailureTracker.Outcome outcome =
|
||||||
|
passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||||
|
if (outcome.isForcedLogout()) {
|
||||||
|
return outcome.isAccountLocked()
|
||||||
|
? "redirect:/login?pwFailExceeded=1&locked=1"
|
||||||
|
: "redirect:/login?pwFailExceeded=1";
|
||||||
|
}
|
||||||
|
redirectAttributes.addFlashAttribute("error", outcome.getMessage());
|
||||||
|
return "redirect:/password/verify";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password/change")
|
@PostMapping("/password/change")
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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.PortalUserAuthService;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
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.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
@@ -22,21 +21,15 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -64,7 +57,6 @@ public class UserRegisterController {
|
|||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
private final PortalProperties portalProperties;
|
private final PortalProperties portalProperties;
|
||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final PortalUserAuthService portalUserAuthService;
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/check_password_match")
|
@PostMapping("/check_password_match")
|
||||||
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
|
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
|
||||||
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
|
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register/confirm_password")
|
@PostMapping("/register/confirm_password")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
|
|||||||
|
|
||||||
|
|
||||||
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
||||||
@PasswordMatch(input = "password", confirm = "password2")
|
@PasswordMatch(input = "password", confirm = "confirmPassword")
|
||||||
@Data
|
@Data
|
||||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||||
public class PortalUserRegistrationDTO {
|
public class PortalUserRegistrationDTO {
|
||||||
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
|
|||||||
*/
|
*/
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
private String password2;
|
|
||||||
|
|
||||||
@CellPhone
|
@CellPhone
|
||||||
private String mobileNumber;
|
private String mobileNumber;
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
|||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.common.exception.SystemException;
|
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||||
import com.eactive.apim.portal.file.service.FileService;
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||||
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
|
|
||||||
approvalService.createUserApproval(newUser);
|
approvalService.createUserApproval(newUser);
|
||||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendActivationEmail(newUser);
|
|
||||||
|
|
||||||
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendActivationEmail(PortalUser newUser) {
|
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
|
||||||
recipient.setUsername(newUser.getUserName());
|
|
||||||
recipient.setUserId(newUser.getEmailAddr());
|
|
||||||
recipient.setPhone(newUser.getMobileNumber());
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
|
|
||||||
|
|
||||||
try {
|
|
||||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
|
||||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
|
||||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
throw new SystemException("암호화 모듈 오류");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
||||||
private ValidationResponse convertExistingUserToCorporate(
|
private ValidationResponse convertExistingUserToCorporate(
|
||||||
PortalUser existingUser,
|
PortalUser existingUser,
|
||||||
|
|||||||
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
|
|||||||
public void withdrawUser(String userId, String withdrawalReason) {
|
public void withdrawUser(String userId, String withdrawalReason) {
|
||||||
PortalUser user = portalUserService.findById(userId);
|
PortalUser user = portalUserService.findById(userId);
|
||||||
|
|
||||||
// 법인 관리자 탈퇴 제한
|
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
|
||||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||||
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
|
throw new IllegalArgumentException(
|
||||||
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
|
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
|
||||||
}
|
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 약관 동의 정보 삭제
|
// 약관 동의 정보 삭제
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
|
|||||||
|
|
||||||
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
|
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
|
||||||
|
|
||||||
ValidationResponse checkPasswordMatch(String password, String password2);
|
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
|
||||||
|
|
||||||
ValidationResponse verifyPassword(String loginId, String confirmPassword);
|
ValidationResponse verifyPassword(String loginId, String confirmPassword);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
||||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
|
|
||||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
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.UserAgreementDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||||
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
|||||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
|
|||||||
|
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
private final PasswordValidator passwordValidator;
|
private final PasswordValidator passwordValidator;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AuthNumberGenerator authNumberGenerator;
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -94,8 +85,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ValidationResponse checkPasswordMatch(String password, String password2) {
|
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
|
||||||
boolean isMatch = password.equals(password2);
|
boolean isMatch = password.equals(confirmPassword);
|
||||||
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
|
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
|
||||||
return new ValidationResponse(isMatch, message);
|
return new ValidationResponse(isMatch, message);
|
||||||
}
|
}
|
||||||
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendEmailActivation(newUser);
|
|
||||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendEmailActivation(PortalUser registeredUser) {
|
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
|
||||||
recipient.setUsername(registeredUser.getUserName());
|
|
||||||
recipient.setUserId(registeredUser.getEmailAddr());
|
|
||||||
recipient.setPhone(registeredUser.getMobileNumber());
|
|
||||||
|
|
||||||
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
|
|
||||||
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
|
|
||||||
params.put("token", tokenValue);
|
|
||||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// String encToken = encryptionUtil.encrypt(tokenValue);
|
|
||||||
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
|
|
||||||
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
// throw new SystemException("암호화 모듈 오류");
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ public class PasswordService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
||||||
|
|
||||||
validatePasswordUpdate(user, newPassword, confirmPassword);
|
validatePasswordUpdate(user, newPassword, confirmPassword);
|
||||||
checkPasswordHistory(user.getLoginId(), newPassword);
|
// PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다.
|
||||||
|
checkPasswordHistory(user.getId(), newPassword);
|
||||||
|
|
||||||
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
|
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
|
||||||
if(histories.isEmpty()) {
|
if(histories.isEmpty()) {
|
||||||
@@ -85,6 +86,17 @@ public class PasswordService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
|
||||||
|
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
|
||||||
|
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
|
||||||
|
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
|
||||||
|
savePasswordHistory(userId, previousPasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
private void checkPasswordHistory(String userId, String newPassword) {
|
private void checkPasswordHistory(String userId, String newPassword) {
|
||||||
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
||||||
|
|
||||||
@@ -115,26 +127,4 @@ public class PasswordService {
|
|||||||
|
|
||||||
passwordHistoryRepository.save(newHistory);
|
passwordHistoryRepository.save(newHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isValidPassword(String password) {
|
|
||||||
if (password.length() < 8) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean hasLetter = false;
|
|
||||||
boolean hasDigit = false;
|
|
||||||
boolean hasSpecial = false;
|
|
||||||
|
|
||||||
for (char c : password.toCharArray()) {
|
|
||||||
if (Character.isLetter(c)) {
|
|
||||||
hasLetter = true;
|
|
||||||
} else if (Character.isDigit(c)) {
|
|
||||||
hasDigit = true;
|
|
||||||
} else if (!Character.isWhitespace(c)) {
|
|
||||||
hasSpecial = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasLetter && hasDigit && hasSpecial;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -54,8 +45,8 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final MessageHandlerService messageHandlerService;
|
private final MessageHandlerService messageHandlerService;
|
||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final LoginFinalizer loginFinalizer;
|
private final LoginFinalizer loginFinalizer;
|
||||||
|
private final PasswordService passwordService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
||||||
@@ -66,7 +57,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
||||||
return buildAuthenticatedUser(portalUser);
|
return buildAuthenticatedUser(portalUser);
|
||||||
} catch (UserNotFoundException e) {
|
} catch (UserNotFoundException e) {
|
||||||
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
// 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
|
||||||
|
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
|
||||||
|
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +155,10 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
|
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
|
||||||
|
|
||||||
String tempPassword = EncryptionUtil.generateNewPassword();
|
String tempPassword = EncryptionUtil.generateNewPassword();
|
||||||
|
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
|
||||||
|
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
|
||||||
|
// 것이 허용되는 보안 허점이 생긴다.
|
||||||
|
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
|
||||||
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
||||||
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
||||||
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
||||||
|
|||||||
@@ -1,53 +1,23 @@
|
|||||||
package com.eactive.apim.portal.apps.user.validator;
|
package com.eactive.apim.portal.apps.user.validator;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
|
||||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 문자열 정책 검사 진입점. 규칙 자체는 정본인 {@link PasswordRuleValidator} 가 갖고,
|
||||||
|
* 여기서는 DB 토글({@link PasswordPolicyProperties})을 얹어 위임만 한다.
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class PasswordValidator {
|
public class PasswordValidator {
|
||||||
|
|
||||||
public boolean isValidPassword(String password) {
|
private final PasswordPolicyProperties passwordPolicyProperties;
|
||||||
if (password == null || password.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String tmpPw = password.toUpperCase();
|
|
||||||
return isValidLengthAndCharacters(tmpPw) && !containsInvalidPatterns(tmpPw);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
||||||
PasswordRuleValidator validator = new PasswordRuleValidator();
|
PasswordRuleValidator validator = new PasswordRuleValidator();
|
||||||
return validator.isValid(password, loginId, mobileNumber);
|
return validator.isValid(password, loginId, mobileNumber,
|
||||||
}
|
passwordPolicyProperties.isKeyboardSequenceBlocked());
|
||||||
|
|
||||||
private boolean isValidLengthAndCharacters(String password) {
|
|
||||||
final int MIN = 8;
|
|
||||||
final int MAX = 50;
|
|
||||||
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
|
|
||||||
return password.matches(REGEX);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean containsInvalidPatterns(String password) {
|
|
||||||
final String SAMEPT = "(\\w)\\1\\1";
|
|
||||||
final String BLANKPT = "(\\s)";
|
|
||||||
|
|
||||||
if (password.matches(BLANKPT) || password.matches(SAMEPT)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return containsContinuousCharacters(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean containsContinuousCharacters(String password) {
|
|
||||||
for (int i = 0; i < password.length() - 2; i++) {
|
|
||||||
if (isContinuous(password.charAt(i), password.charAt(i + 1), password.charAt(i + 2))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isContinuous(char a, char b, char c) {
|
|
||||||
return (b - a == 1) && (c - b == 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import com.eactive.apim.portal.config.PortalProperties;
|
|||||||
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
||||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
|
||||||
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
import com.eactive.apim.portal.djb.footer.RelatedSite;
|
||||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
||||||
|
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
@@ -32,6 +34,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ClientGuardService clientGuardService;
|
private ClientGuardService clientGuardService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordPolicyProperties passwordPolicyProperties;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PortalPropertyService portalPropertyService;
|
private PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
@@ -47,6 +52,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private Environment environment;
|
private Environment environment;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordCryptoProperties passwordCryptoProperties;
|
||||||
|
|
||||||
@ModelAttribute("breadcrumb")
|
@ModelAttribute("breadcrumb")
|
||||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||||
String currentPath = request.getRequestURI();
|
String currentPath = request.getRequestURI();
|
||||||
@@ -111,6 +119,47 @@ public class GlobalControllerAdvice {
|
|||||||
return clientGuardService.isDevtoolsGuardEnabled();
|
return clientGuardService.isDevtoolsGuardEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 정책 - 키보드 배열 연속 문자 차단 여부.
|
||||||
|
* PortalProperty(Portal/password.keyboard-sequence.block)에서 조회.
|
||||||
|
* head 의 window.__PASSWORD_POLICY__ 와 비밀번호 요구사항 체크리스트 노출에 함께 쓴다.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("passwordKeyboardSequenceBlock")
|
||||||
|
public boolean passwordKeyboardSequenceBlock() {
|
||||||
|
return passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 전송암호화 설정(yml {@code portal.security.password-encrypt.*}).
|
||||||
|
* head 의 {@code window.__PASSWORD_CRYPTO__} 로 내려가 password-crypto.js 가 읽는다.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code enabled} - 꺼져 있으면 화면은 아무것도 하지 않고 평문 전송한다.</li>
|
||||||
|
* <li>{@code policy} - 평문 비밀번호 처리 정책. 화면은 경고 팝업 노출 여부·문구를 여기서 정한다.</li>
|
||||||
|
* <li>{@code softwareFallback} - {@code crypto.subtle} 이 없을 때 forge 번들을 내려받아 쓸지.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@ModelAttribute("passwordCrypto")
|
||||||
|
public Map<String, Object> passwordCrypto() {
|
||||||
|
Map<String, Object> config = new java.util.LinkedHashMap<>();
|
||||||
|
config.put("enabled", passwordCryptoProperties.isEnabled());
|
||||||
|
config.put("policy", passwordCryptoProperties.getPlaintextPolicy().name());
|
||||||
|
config.put("softwareFallback", passwordCryptoProperties.isSoftwareFallback());
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||||
|
*/
|
||||||
|
@ModelAttribute("activeProfileBadge")
|
||||||
|
public String activeProfileBadge() {
|
||||||
|
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] activeProfiles = environment.getActiveProfiles();
|
||||||
|
return activeProfiles.length == 0 ? "default" : String.join(", ", activeProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
|
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
|
||||||
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
|
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
|
||||||
@@ -121,6 +170,61 @@ public class GlobalControllerAdvice {
|
|||||||
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 페이지 본문에 노출되는 브랜드명. PortalProperty(Portal/brand.name)에서 조회.
|
||||||
|
* 로고 이미지(alt 텍스트)는 별도이며 이 값의 영향을 받지 않는다.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("brandName")
|
||||||
|
public String brandName() {
|
||||||
|
return portalPropertyService.getOrCreateProperty(
|
||||||
|
"Portal", "brand.name", "DJBank", "메인 페이지 브랜드명 표기");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* brandName 뒤에 바로 붙는 주격 조사(이/가). 받침 유무에 따라 관리자가 값을 바꿔도 문법이 깨지지 않도록 계산한다.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("brandNameJosaGa")
|
||||||
|
public String brandNameJosaGa() {
|
||||||
|
return hasBatchim(brandName()) ? "이" : "가";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* brandName 뒤에 바로 붙는 보조사(은/는).
|
||||||
|
*/
|
||||||
|
@ModelAttribute("brandNameJosaEun")
|
||||||
|
public String brandNameJosaEun() {
|
||||||
|
return hasBatchim(brandName()) ? "은" : "는";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 헤더(GNB) 로고 이미지 경로. PortalProperty(Portal/brand.logo.header.path)에서 조회.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("brandLogoHeaderPath")
|
||||||
|
public String brandLogoHeaderPath() {
|
||||||
|
return portalPropertyService.getOrCreateProperty(
|
||||||
|
"Portal", "brand.logo.header.path", "/img/logo/logo-djb.png", "헤더(GNB) 로고 이미지 경로");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 푸터 로고 이미지 경로. PortalProperty(Portal/brand.logo.footer.path)에서 조회.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("brandLogoFooterPath")
|
||||||
|
public String brandLogoFooterPath() {
|
||||||
|
return portalPropertyService.getOrCreateProperty(
|
||||||
|
"Portal", "brand.logo.footer.path", "/img/logo/logo-jjb.png", "푸터 로고 이미지 경로");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasBatchim(String word) {
|
||||||
|
if (word == null || word.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char last = word.charAt(word.length() - 1);
|
||||||
|
if (last >= 0xAC00 && last <= 0xD7A3) {
|
||||||
|
return (last - 0xAC00) % 28 != 0;
|
||||||
|
}
|
||||||
|
return "AEIOUaeiou".indexOf(last) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
|
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+3
-6
@@ -1,9 +1,7 @@
|
|||||||
package com.eactive.apim.portal.common.exception;
|
package com.eactive.apim.portal.common.exception;
|
||||||
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||||
@@ -195,10 +193,9 @@ public class PortalGlobalExceptionHandler {
|
|||||||
*/
|
*/
|
||||||
@ExceptionHandler(value = Exception.class)
|
@ExceptionHandler(value = Exception.class)
|
||||||
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
||||||
String requestParams = request.getParameterMap().entrySet()
|
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
|
||||||
.stream()
|
// 이 시점의 파라미터는 이미 복호화된 평문이다.
|
||||||
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
|
|
||||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||||
|
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.eactive.apim.portal.common.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 요청이 허용 빈도를 초과했을 때 던진다(무제한 요청 차단).
|
||||||
|
*
|
||||||
|
* <p>JSON 응답 컨트롤러에서만 사용한다 — 폼 전송 컨트롤러는 예외 대신
|
||||||
|
* flash 메시지로 되돌려야 하므로 {@code isExceeded} 판정을 직접 쓴다.</p>
|
||||||
|
*/
|
||||||
|
public class TooManyWriteRequestsException extends RuntimeException {
|
||||||
|
|
||||||
|
public TooManyWriteRequestsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.groovy.util.Maps;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계정 잠금 처리 단일 지점.
|
||||||
|
*
|
||||||
|
* <p>로그인 연속 실패(`PortalAuthenticationFailureHandler`)와 본인확인 연속 실패
|
||||||
|
* (`PasswordConfirmFailureTracker`)가 같은 방식으로 계정을 잠그도록 로직을 모았다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AccountLockService {
|
||||||
|
|
||||||
|
private final PortalUserRepository portalUserRepository;
|
||||||
|
private final MessageHandlerService messageHandlerService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계정을 잠그고 잠금 알림을 발송한다. 이미 잠긴 계정이면 알림을 중복 발송하지 않는다.
|
||||||
|
*
|
||||||
|
* <p>호출자의 트랜잭션에 참여한다 — 로그인 실패 핸들러는 이미 자신만의
|
||||||
|
* {@code REQUIRES_NEW} 트랜잭션에서 실행되므로 별도 전파를 두면 같은 row 를 두 트랜잭션이
|
||||||
|
* 잠그게 된다.</p>
|
||||||
|
*
|
||||||
|
* @param reason 알림 문구에 실을 잠금 사유
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void lock(PortalUser user, String reason) {
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("Y".equals(user.getAccountLockYn())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setAccountLockYn("Y");
|
||||||
|
portalUserRepository.save(user);
|
||||||
|
messageHandlerService.publishEvent(MessageCode.USER_ACCOUNT_LOCKED,
|
||||||
|
MessageRecipient.of(user), Maps.of("reason", reason));
|
||||||
|
log.warn("계정 잠금 처리 userId={} reason={}", user.getId(), reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중요 페이지 진입용 비밀번호 재인증(본인확인)의 연속 실패 정책을 DB(PortalProperty)에서 조회한다.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code password.confirm.failure.limit} - 연속 실패 허용 횟수 (기본 {@value #DEFAULT_LIMIT})</li>
|
||||||
|
* <li>{@code password.confirm.failure.action} - 임계 초과 시 조치 ({@code LOGOUT} / {@code LOCK})</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PasswordConfirmFailurePolicy {
|
||||||
|
|
||||||
|
private static final String GROUP = "Portal";
|
||||||
|
private static final String NAME_LIMIT = "password.confirm.failure.limit";
|
||||||
|
private static final String NAME_ACTION = "password.confirm.failure.action";
|
||||||
|
|
||||||
|
/** 프로퍼티 미존재/파싱 실패 시 기본 허용 횟수 */
|
||||||
|
public static final int DEFAULT_LIMIT = 5;
|
||||||
|
|
||||||
|
/** 임계 초과 시 조치 */
|
||||||
|
public enum Action {
|
||||||
|
/** 세션만 강제 종료 (기본) */
|
||||||
|
LOGOUT,
|
||||||
|
/** 계정을 잠근 뒤 세션도 종료 — 재로그인 자체가 차단된다 */
|
||||||
|
LOCK;
|
||||||
|
|
||||||
|
public static final Action DEFAULT = LOGOUT;
|
||||||
|
|
||||||
|
public static Action from(String raw) {
|
||||||
|
if (raw == null || raw.trim().isEmpty()) {
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Action.valueOf(raw.trim().toUpperCase());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("{} 값이 유효하지 않음('{}') - 기본값 {} 사용", NAME_ACTION, raw, DEFAULT);
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 연속 실패가 이 값 이상이면 {@link #action()} 을 수행한다. */
|
||||||
|
public int limit() {
|
||||||
|
String raw = portalPropertyService.getOrCreateProperty(
|
||||||
|
GROUP, NAME_LIMIT, String.valueOf(DEFAULT_LIMIT),
|
||||||
|
"본인확인(비밀번호 재인증) 연속 실패 허용 횟수 (이 값 이상 실패 시 조치)");
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(raw.trim());
|
||||||
|
if (parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
log.warn("{} 값이 0 이하({}) - 기본값 {} 사용", NAME_LIMIT, parsed, DEFAULT_LIMIT);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME_LIMIT, raw, DEFAULT_LIMIT);
|
||||||
|
}
|
||||||
|
return DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 임계 초과 시 조치 */
|
||||||
|
public Action action() {
|
||||||
|
return Action.from(portalPropertyService.getOrCreateProperty(
|
||||||
|
GROUP, NAME_ACTION, Action.DEFAULT.name(),
|
||||||
|
"본인확인 연속 실패 임계 초과 시 조치 (LOGOUT=강제 로그아웃, LOCK=계정 차단 후 로그아웃)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordConfirmFailurePolicy.Action;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중요 페이지 진입 시 비밀번호 재인증(본인확인)의 연속 실패를 추적하고 임계 초과 시 조치한다.
|
||||||
|
*
|
||||||
|
* <p>무차별 대입 방어. 카운터는 세션 attribute 로 유지하고, 임계 초과 시
|
||||||
|
* {@link PasswordConfirmFailurePolicy.Action} 에 따라 강제 로그아웃하거나 계정을 잠근다.
|
||||||
|
* step-up 확인 페이지·webhook·비밀번호 변경 진입이 모두 이 클래스를 쓴다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PasswordConfirmFailureTracker {
|
||||||
|
|
||||||
|
/** 연속 실패 횟수 세션 attribute 키 */
|
||||||
|
public static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT";
|
||||||
|
|
||||||
|
private final PasswordConfirmFailurePolicy policy;
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
private final AccountLockService accountLockService;
|
||||||
|
private final PortalUserRepository portalUserRepository;
|
||||||
|
|
||||||
|
/** 실패 처리 결과 */
|
||||||
|
@Getter
|
||||||
|
public static class Outcome {
|
||||||
|
|
||||||
|
private final int failCount;
|
||||||
|
private final int limit;
|
||||||
|
private final boolean forcedLogout;
|
||||||
|
private final boolean accountLocked;
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
Outcome(int failCount, int limit, boolean forcedLogout, boolean accountLocked, String message) {
|
||||||
|
this.failCount = failCount;
|
||||||
|
this.limit = limit;
|
||||||
|
this.forcedLogout = forcedLogout;
|
||||||
|
this.accountLocked = accountLocked;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확인 성공 시 카운터 초기화 */
|
||||||
|
public void reset(HttpSession session) {
|
||||||
|
if (session != null) {
|
||||||
|
session.removeAttribute(ATTR_FAIL_COUNT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 1회를 기록하고, 임계에 도달했으면 정책에 따라 조치(로그아웃 또는 계정 잠금 후 로그아웃)한다.
|
||||||
|
* 조치가 수행되면 호출자는 로그인 화면으로 보내기만 하면 된다.
|
||||||
|
*/
|
||||||
|
public Outcome recordFailure(HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
int limit = policy.limit();
|
||||||
|
int failCount = increment(session);
|
||||||
|
|
||||||
|
if (failCount < limit) {
|
||||||
|
return new Outcome(failCount, limit, false, false,
|
||||||
|
policy.action() == Action.LOCK
|
||||||
|
? "현재 비밀번호가 일치하지 않습니다. 보안을 위해 반복 실패 시 계정 이용이 제한됩니다."
|
||||||
|
: "현재 비밀번호가 일치하지 않습니다. 보안을 위해 반복 실패 시 자동 로그아웃됩니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean locked = false;
|
||||||
|
if (policy.action() == Action.LOCK) {
|
||||||
|
locked = lockCurrentUser(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
forceLogout(session, request, response);
|
||||||
|
log.warn("본인확인 비밀번호 반복 실패로 {} 처리", locked ? "계정 차단" : "강제 로그아웃");
|
||||||
|
|
||||||
|
return new Outcome(failCount, limit, true, locked,
|
||||||
|
locked
|
||||||
|
? "비밀번호 확인 반복 실패로 계정이 차단되었습니다. 관리자에게 문의해 주세요."
|
||||||
|
: "비밀번호 확인 반복 실패로 로그아웃되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private int increment(HttpSession session) {
|
||||||
|
Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT);
|
||||||
|
int next = (count == null ? 0 : count) + 1;
|
||||||
|
session.setAttribute(ATTR_FAIL_COUNT, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 사용자를 잠근다. 로그인 실패 카운트도 임계치로 맞춰 두어야
|
||||||
|
* 세션을 새로 받아도 잠금 상태가 이어진다.
|
||||||
|
*/
|
||||||
|
private boolean lockCurrentUser(int limit) {
|
||||||
|
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
if (current == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Optional<PortalUser> found = portalUserRepository.findById(current.getId());
|
||||||
|
if (!found.isPresent()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
PortalUser user = found.get();
|
||||||
|
if (user.getLoginFailureCount() == null || user.getLoginFailureCount() < limit) {
|
||||||
|
user.setLoginFailureCount(limit);
|
||||||
|
portalUserRepository.save(user);
|
||||||
|
}
|
||||||
|
accountLockService.lock(user, "본인확인 비밀번호 반복 실패로 인한 계정 차단");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void forceLogout(HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
// SecurityContextLogoutHandler 는 HTTP 세션만 무효화하므로 DB 세션 레코드를 먼저 정리한다.
|
||||||
|
userSessionService.removeSession(session.getId());
|
||||||
|
new SecurityContextLogoutHandler().logout(request, response,
|
||||||
|
SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 문자열 정책 중 DB(PortalProperty)로 on/off 하는 항목을 조회한다.
|
||||||
|
*
|
||||||
|
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
|
||||||
|
* 의 자동 생성이 동작하도록 한다.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code password.keyboard-sequence.block} - 키보드 배열 연속 문자(qwe/asd 등) 사용 금지</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PasswordPolicyProperties {
|
||||||
|
|
||||||
|
private static final String GROUP = "Portal";
|
||||||
|
private static final String NAME_KEYBOARD_SEQUENCE = "password.keyboard-sequence.block";
|
||||||
|
|
||||||
|
/** 프로퍼티 미존재/파싱 실패 시 기본값 (차단) */
|
||||||
|
public static final boolean DEFAULT_KEYBOARD_SEQUENCE_BLOCK = true;
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 키보드 배열 연속 문자 3자리 이상 사용 금지 여부 */
|
||||||
|
public boolean isKeyboardSequenceBlocked() {
|
||||||
|
return Boolean.parseBoolean(
|
||||||
|
portalPropertyService.getOrCreateProperty(GROUP, NAME_KEYBOARD_SEQUENCE,
|
||||||
|
String.valueOf(DEFAULT_KEYBOARD_SEQUENCE_BLOCK),
|
||||||
|
"키보드 배열 연속 문자(qwe·asd 등) 3자리 이상 사용 금지 여부 (true/false)").trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.ApplicationListener;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기동 시 보안 정책 PTL_PROPERTY 를 미리 생성한다.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY 에는 (GROUP, NAME) 유니크 제약이 없어
|
||||||
|
* {@code getOrCreateProperty} 최초 조회가 동시 요청으로 경합하면 같은 키가 중복 INSERT 된다
|
||||||
|
* (중복 시 Hibernate 가 {@code More than one row with the given identifier} 로 실패한다).
|
||||||
|
* 특히 {@link PasswordPolicyProperties} 는 GlobalControllerAdvice 를 통해 <b>매 요청</b> 조회되므로
|
||||||
|
* 기동 직후 동시 접속에서 경합할 가능성이 높다.</p>
|
||||||
|
*
|
||||||
|
* <p>부팅 완료 시점에 단일 스레드로 한 번 조회해 두면 이후 요청은 항상 기존 행을 읽는다.
|
||||||
|
* {@code PortalPropertyDuplicateChecker} 보다 먼저 실행되도록 우선순위를 높인다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class SecurityPolicyPropertySeeder implements ApplicationListener<ApplicationReadyEvent> {
|
||||||
|
|
||||||
|
private final PasswordPolicyProperties passwordPolicyProperties;
|
||||||
|
private final WriteRateLimitPolicy writeRateLimitPolicy;
|
||||||
|
private final PasswordConfirmFailurePolicy passwordConfirmFailurePolicy;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||||
|
try {
|
||||||
|
boolean keyboardSequenceBlock = passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||||
|
int writeLimit = writeRateLimitPolicy.perMinuteLimit();
|
||||||
|
int confirmLimit = passwordConfirmFailurePolicy.limit();
|
||||||
|
PasswordConfirmFailurePolicy.Action confirmAction = passwordConfirmFailurePolicy.action();
|
||||||
|
log.info("[보안 정책] keyboard-sequence.block={}, write.rate-limit.per-minute={}, "
|
||||||
|
+ "password.confirm.failure.limit={}, password.confirm.failure.action={}",
|
||||||
|
keyboardSequenceBlock, writeLimit, confirmLimit, confirmAction);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[보안 정책] PTL_PROPERTY 사전 생성 실패 — 최초 요청 시 생성된다", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물·댓글·피드백 작성의 1분당 허용 건수를 DB(PortalProperty)에서 조회한다.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY (group={@code Portal}, name={@code write.rate-limit.per-minute}) 값으로 제어한다.
|
||||||
|
* 허용 범위는 {@value #MIN_LIMIT}~{@value #MAX_LIMIT} 이며 벗어나거나 숫자가 아니면 기본값
|
||||||
|
* {@value #DEFAULT_LIMIT} 로 동작한다. {@code 0} 은 제한 없음을 뜻한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WriteRateLimitPolicy {
|
||||||
|
|
||||||
|
private static final String GROUP = "Portal";
|
||||||
|
private static final String NAME = "write.rate-limit.per-minute";
|
||||||
|
|
||||||
|
/** 프로퍼티 미존재/파싱 실패 시 기본 허용 건수 */
|
||||||
|
public static final int DEFAULT_LIMIT = 1;
|
||||||
|
private static final int MIN_LIMIT = 0;
|
||||||
|
private static final int MAX_LIMIT = 10;
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 1분당 작성 허용 건수. 0 이면 제한하지 않는다. */
|
||||||
|
public int perMinuteLimit() {
|
||||||
|
String raw = portalPropertyService.getOrCreateProperty(
|
||||||
|
GROUP, NAME, String.valueOf(DEFAULT_LIMIT),
|
||||||
|
"1분당 게시물·댓글·피드백 작성 허용 건수 (" + MIN_LIMIT + "~" + MAX_LIMIT + ", 0=제한없음)");
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(raw.trim());
|
||||||
|
if (parsed >= MIN_LIMIT && parsed <= MAX_LIMIT) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
log.warn("{} 값이 허용 범위({}~{}) 밖({}) - 기본값 {} 사용", NAME, MIN_LIMIT, MAX_LIMIT, parsed, DEFAULT_LIMIT);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME, raw, DEFAULT_LIMIT);
|
||||||
|
}
|
||||||
|
return DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.eactive.apim.portal.common.security;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||||
|
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
|
||||||
|
import com.eactive.apim.portal.common.entity.Auditable;
|
||||||
|
import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 요청 빈도 제한(무제한 요청 차단).
|
||||||
|
*
|
||||||
|
* <p>카운터는 대상별로 독립이며 저장소는 DB 다 — 세션 재발급·다중 탭으로 우회할 수 없다.
|
||||||
|
* 허용 건수는 {@link WriteRateLimitPolicy} 가 PortalProperty 에서 읽는다.</p>
|
||||||
|
*
|
||||||
|
* <p>판정은 "최신 N건을 뽑아 N번째가 1분 이내인가" 로 한다. {@code createdDate} 는
|
||||||
|
* {@code LocalDateTimeToStringConverter} 로 문자열 저장되므로 쿼리에서 범위 비교하지 않고
|
||||||
|
* Java 에서 비교한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WriteRateLimitService {
|
||||||
|
|
||||||
|
/** 빈도 판정 기준 구간 */
|
||||||
|
private static final Duration WINDOW = Duration.ofMinutes(1);
|
||||||
|
|
||||||
|
/** 빈도 제한 대상. 대상별로 카운터가 독립이다. */
|
||||||
|
public enum WriteTarget {
|
||||||
|
INQUIRY("Q&A 문의"),
|
||||||
|
INQUIRY_COMMENT("댓글"),
|
||||||
|
PARTNERSHIP("피드백/개선요청");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
WriteTarget(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final WriteRateLimitPolicy writeRateLimitPolicy;
|
||||||
|
private final InquiryRepository inquiryRepository;
|
||||||
|
private final InquiryCommentRepository inquiryCommentRepository;
|
||||||
|
private final PartnershipApplicationRepository partnershipApplicationRepository;
|
||||||
|
|
||||||
|
/** 현재 로그인 사용자 기준으로 허용 빈도를 넘었는지 */
|
||||||
|
public boolean isExceeded(WriteTarget target) {
|
||||||
|
return isExceeded(target, currentUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 허용 빈도를 넘었는지. userId 가 없으면(비인증) 판정하지 않는다. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isExceeded(WriteTarget target, String userId) {
|
||||||
|
int limit = writeRateLimitPolicy.perMinuteLimit();
|
||||||
|
if (limit <= 0 || userId == null || userId.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<? extends Auditable> recent = findRecent(target, userId, limit);
|
||||||
|
if (recent.size() < limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime oldestOfWindow = recent.get(limit - 1).getCreatedDate();
|
||||||
|
boolean exceeded = oldestOfWindow != null && oldestOfWindow.isAfter(LocalDateTime.now().minus(WINDOW));
|
||||||
|
if (exceeded) {
|
||||||
|
log.warn("작성 빈도 제한 초과 target={} limit={}/분", target, limit);
|
||||||
|
}
|
||||||
|
return exceeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 허용 빈도를 넘었으면 예외. JSON 응답 컨트롤러 전용. */
|
||||||
|
public void assertNotExceeded(WriteTarget target) {
|
||||||
|
if (isExceeded(target)) {
|
||||||
|
throw new TooManyWriteRequestsException(exceededMessage(target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사용자에게 보여줄 차단 안내 문구 */
|
||||||
|
public String exceededMessage(WriteTarget target) {
|
||||||
|
return target.getLabel() + " 작성은 1분에 " + writeRateLimitPolicy.perMinuteLimit()
|
||||||
|
+ "건까지 가능합니다. 잠시 후 다시 시도해 주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 감사 필드 createdBy 와 동일한 식별자(PortalUser.id). 비인증이면 null. */
|
||||||
|
private static String currentUserId() {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
return user == null ? null : user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<? extends Auditable> findRecent(WriteTarget target, String userId, int limit) {
|
||||||
|
PageRequest page = PageRequest.of(0, limit);
|
||||||
|
switch (target) {
|
||||||
|
case INQUIRY:
|
||||||
|
return inquiryRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
|
||||||
|
case INQUIRY_COMMENT:
|
||||||
|
return inquiryCommentRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
|
||||||
|
case PARTNERSHIP:
|
||||||
|
return partnershipApplicationRepository.findByCreatedByOrderByCreatedDateDesc(userId, page);
|
||||||
|
default:
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||||
|
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멀티파트(파일 업로드 동반) 요청 전용 복호화 래퍼.
|
||||||
|
*
|
||||||
|
* <p><b>배경</b> — {@link DecryptingRequestWrapper} 는 {@code getParameter()} 호출 시
|
||||||
|
* {@code super.getParameter()} 위임 결과를 복호화하는 방식이다. 그런데 Spring 의
|
||||||
|
* {@link org.springframework.web.multipart.support.MultipartFilter} 가 만드는
|
||||||
|
* {@code MultipartHttpServletRequest} 는 자신이 직접 파싱한 폼 필드(파일이 아닌 text part) 값을
|
||||||
|
* {@code getParameter()} 에서 곧바로 반환하고 {@code super.getParameter()} 로 위임하지 않는다.
|
||||||
|
* 그 결과 {@code DecryptingRequestWrapper} 가 멀티파트 파싱 "이전"(안쪽)에 씌워지면, 멀티파트 안의
|
||||||
|
* 비밀번호 파라미터는 복호화되지 않은 봉투(ENC1) 문자열 그대로 컨트롤러까지 전달된다 — 법인가입처럼
|
||||||
|
* 파일 첨부(사업자등록증)와 비밀번호가 같은 폼에 있는 경우 재현된다.
|
||||||
|
*
|
||||||
|
* <p><b>해결</b> — {@link PasswordDecryptFilter} 를 MultipartFilter "이후"에 실행되도록 순서를
|
||||||
|
* 옮기면(그리고 Lucy XSS 필터보다는 여전히 앞서도록), 이 필터가 감싸는 대상이 이미
|
||||||
|
* {@code MultipartHttpServletRequest} 다. 이때는 이 클래스로 감싸 {@link MultipartHttpServletRequest}
|
||||||
|
* 인터페이스 자체를 구현하고 파일 관련 메서드는 원본 멀티파트 요청에 위임한다 — Spring 의
|
||||||
|
* {@code MultipartFile} 바인딩({@code WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class)})이
|
||||||
|
* 이 래퍼를 그대로 인식하도록 하기 위함이다. 파라미터(텍스트 필드) 복호화 로직은 부모
|
||||||
|
* {@link DecryptingRequestWrapper} 그대로 재사용한다.
|
||||||
|
*/
|
||||||
|
public class DecryptingMultipartRequestWrapper extends DecryptingRequestWrapper
|
||||||
|
implements MultipartHttpServletRequest {
|
||||||
|
|
||||||
|
private final MultipartHttpServletRequest multipartRequest;
|
||||||
|
|
||||||
|
public DecryptingMultipartRequestWrapper(MultipartHttpServletRequest request,
|
||||||
|
PasswordEnvelopeCodec codec,
|
||||||
|
PasswordKeyStore keyStore,
|
||||||
|
PasswordCryptoProperties properties) {
|
||||||
|
super(request, codec, keyStore, properties);
|
||||||
|
this.multipartRequest = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Iterator<String> getFileNames() {
|
||||||
|
return multipartRequest.getFileNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MultipartFile getFile(String name) {
|
||||||
|
return multipartRequest.getFile(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MultipartFile> getFiles(String name) {
|
||||||
|
return multipartRequest.getFiles(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, MultipartFile> getFileMap() {
|
||||||
|
return multipartRequest.getFileMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MultiValueMap<String, MultipartFile> getMultiFileMap() {
|
||||||
|
return multipartRequest.getMultiFileMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getMultipartContentType(String paramOrFileName) {
|
||||||
|
return multipartRequest.getMultipartContentType(paramOrFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HttpMethod getRequestMethod() {
|
||||||
|
return multipartRequest.getRequestMethod();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HttpHeaders getRequestHeaders() {
|
||||||
|
return multipartRequest.getRequestHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public HttpHeaders getMultipartHeaders(String paramOrFileName) {
|
||||||
|
return multipartRequest.getMultipartHeaders(paramOrFileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletRequestWrapper;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봉투(ENC1) 형식으로 들어온 파라미터 값을 평문으로 되돌리는 요청 래퍼.
|
||||||
|
*
|
||||||
|
* <p><b>지연 복호화가 필수다.</b> 생성자에서 {@code getParameterMap()} 을 부르면 컨테이너가 요청 본문을
|
||||||
|
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
|
||||||
|
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
|
||||||
|
*
|
||||||
|
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 3})보다 <b>앞</b>({@code MIN_VALUE + 2})에서 이 래퍼가
|
||||||
|
* 씌워지므로, 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든
|
||||||
|
* 비밀번호의 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>멀티파트(파일 업로드) 요청 주의</b> — {@code getParameter()} 는 {@code super.getParameter()} 위임
|
||||||
|
* 결과를 복호화하는 방식이라, 이 래퍼가 감싸는 시점에 {@code super} 가 이미 멀티파트 파싱이 끝난
|
||||||
|
* {@code MultipartHttpServletRequest} 여야만 폼 필드(파일이 아닌 text part)를 볼 수 있다 — Spring 의
|
||||||
|
* 멀티파트 파라미터 맵은 자신이 파싱한 값을 곧바로 반환하고 감싸고 있는 원본 요청으로 위임하지 않기
|
||||||
|
* 때문이다. 그래서 {@link PasswordDecryptFilter} 는 MultipartFilter({@code order = MIN_VALUE + 1})
|
||||||
|
* "이후"에 실행되도록 등록돼 있고, 감싸는 대상이 {@code MultipartHttpServletRequest} 면 이 클래스 대신
|
||||||
|
* {@link DecryptingMultipartRequestWrapper}(이 클래스를 상속하며 멀티파트 인터페이스도 구현)를 쓴다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
||||||
|
|
||||||
|
private final PasswordEnvelopeCodec codec;
|
||||||
|
private final PasswordKeyStore keyStore;
|
||||||
|
private final PasswordCryptoProperties properties;
|
||||||
|
|
||||||
|
/** 봉투 원문 → 평문. 같은 값이 여러 번 조회돼도 RSA 연산은 한 번만 한다. */
|
||||||
|
private final Map<String, String> decrypted = new HashMap<>();
|
||||||
|
|
||||||
|
/** 요청 처리 후 폐기할 keyId(1회용 키 모드). */
|
||||||
|
private final Set<String> usedKeyIds = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
private Map<String, String[]> parameterMapCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
|
||||||
|
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
|
||||||
|
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
|
||||||
|
*/
|
||||||
|
private boolean plaintextWarned;
|
||||||
|
|
||||||
|
public DecryptingRequestWrapper(HttpServletRequest request,
|
||||||
|
PasswordEnvelopeCodec codec,
|
||||||
|
PasswordKeyStore keyStore,
|
||||||
|
PasswordCryptoProperties properties) {
|
||||||
|
super(request);
|
||||||
|
this.codec = codec;
|
||||||
|
this.keyStore = keyStore;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getParameter(String name) {
|
||||||
|
return convert(name, super.getParameter(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] getParameterValues(String name) {
|
||||||
|
String[] values = super.getParameterValues(name);
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] converted = new String[values.length];
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
converted[i] = convert(name, values[i]);
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String[]> getParameterMap() {
|
||||||
|
if (parameterMapCache == null) {
|
||||||
|
Map<String, String[]> source = super.getParameterMap();
|
||||||
|
Map<String, String[]> result = new HashMap<>(Math.max(16, source.size() * 2));
|
||||||
|
for (Map.Entry<String, String[]> entry : source.entrySet()) {
|
||||||
|
String name = entry.getKey();
|
||||||
|
String[] values = entry.getValue();
|
||||||
|
String[] converted = new String[values.length];
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
converted[i] = convert(name, values[i]);
|
||||||
|
}
|
||||||
|
result.put(name, converted);
|
||||||
|
}
|
||||||
|
parameterMapCache = Collections.unmodifiableMap(result);
|
||||||
|
}
|
||||||
|
return parameterMapCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 요청 처리가 끝난 뒤 필터가 호출한다. 1회용 키를 폐기해 재전송을 막는다. */
|
||||||
|
void consumeUsedKeys() {
|
||||||
|
for (String keyId : usedKeyIds) {
|
||||||
|
keyStore.consume(keyId, (HttpServletRequest) getRequest());
|
||||||
|
}
|
||||||
|
usedKeyIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String convert(String name, String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (!codec.isEnvelope(value)) {
|
||||||
|
return handlePlaintext(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
String cached = decrypted.get(value);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
String keyId = codec.keyIdOf(value);
|
||||||
|
if (keyId == null) {
|
||||||
|
log.warn("봉투 형식 오류 - uri={}, param={}", getRequestURI(), name);
|
||||||
|
return failed(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
PrivateKey privateKey = keyStore.resolve(keyId, (HttpServletRequest) getRequest());
|
||||||
|
if (privateKey == null) {
|
||||||
|
log.warn("전송암호화 키를 찾을 수 없음(만료·인스턴스 불일치) - uri={}, param={}, keyId={}",
|
||||||
|
getRequestURI(), name, keyId);
|
||||||
|
return failed(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String plain = codec.decrypt(value, privateKey);
|
||||||
|
decrypted.put(value, plain);
|
||||||
|
usedKeyIds.add(keyId);
|
||||||
|
return plain;
|
||||||
|
} catch (PasswordDecryptException e) {
|
||||||
|
log.warn("전송암호화 복호화 실패 - uri={}, param={}, keyId={}, reason={}",
|
||||||
|
getRequestURI(), name, keyId, e.getMessage());
|
||||||
|
usedKeyIds.add(keyId);
|
||||||
|
return failed(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봉투가 씌워지지 않은 파라미터 처리. 비밀번호 계열이 아니면 그대로 통과시킨다.
|
||||||
|
*
|
||||||
|
* <p>{@code ENFORCE} 에서 예외를 던지면 로그인 경로가 500 이 되므로 빈 값으로 바꿔
|
||||||
|
* 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.</p>
|
||||||
|
*/
|
||||||
|
private String handlePlaintext(String name, String value) {
|
||||||
|
PasswordCryptoProperties.PlaintextPolicy policy = properties.getPlaintextPolicy();
|
||||||
|
if (policy == PasswordCryptoProperties.PlaintextPolicy.NONE
|
||||||
|
|| !PasswordParamNames.isPasswordLike(name)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean reject = policy == PasswordCryptoProperties.PlaintextPolicy.ENFORCE;
|
||||||
|
if (!plaintextWarned) {
|
||||||
|
plaintextWarned = true;
|
||||||
|
log.warn("암호화되지 않은 비밀번호 파라미터 {} - policy={}, transport={}, uri={}, param={}",
|
||||||
|
reject ? "거부" : "허용", policy,
|
||||||
|
RequestTransport.isSecure((HttpServletRequest) getRequest()) ? "https" : "http",
|
||||||
|
getRequestURI(), name);
|
||||||
|
}
|
||||||
|
return reject ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
|
||||||
|
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
|
||||||
|
*/
|
||||||
|
private String failed(String rawValue) {
|
||||||
|
decrypted.put(rawValue, "");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트에 내려줄 공개키 1건. {@code PasswordKeyController} 응답 본문이기도 하다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class IssuedKey {
|
||||||
|
|
||||||
|
/** 봉투(ENC1)의 두 번째 세그먼트로 되돌아오는 키 식별자. */
|
||||||
|
private final String keyId;
|
||||||
|
|
||||||
|
/** X.509 SubjectPublicKeyInfo(SPKI) DER 을 표준 base64 로 인코딩한 값. */
|
||||||
|
private final String publicKey;
|
||||||
|
|
||||||
|
/** 남은 수명(초). 클라이언트 캐시 판단용. */
|
||||||
|
private final int expiresIn;
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
|
||||||
|
*
|
||||||
|
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>이 기능은 XSS 방어도, MITM 방어도 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있고,
|
||||||
|
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. "전송 페이로드 평문 노출" 점검 지적에
|
||||||
|
* 대한 대응 범위로만 이해할 것.</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "portal.security.password-encrypt")
|
||||||
|
public class PasswordCryptoProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 개인키 보관 범위.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code REQUEST} - 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기. 재전송 공격 차단이 가장 강하다.</li>
|
||||||
|
* <li>{@code SESSION} - 세션 단위로 키를 보관. 세션 복제 환경에 유리하다.</li>
|
||||||
|
* <li>{@code SERVER} - 서버 고정 키쌍 + TTL 로테이션. 무상태라 가장 단순하지만 재전송 방지 수단이 없다.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public enum KeyScope {
|
||||||
|
REQUEST, SESSION, SERVER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봉투(ENC1)가 적용되지 않은 <b>평문 비밀번호 파라미터</b>를 서버가 어떻게 다룰지.
|
||||||
|
* 전송 구간(HTTP/HTTPS) 자체를 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code NONE} - 무동작. 평문을 그대로 받는다.</li>
|
||||||
|
* <li>{@code PERMISSIVE} - 평문을 받되 서버 로그에 경고를 남기고, 화면에도 경고 팝업을 띄운다.</li>
|
||||||
|
* <li>{@code ENFORCE} - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>{@code ENFORCE} 는 전송 구간이 HTTP 여도 안전하게 켤 수 있다. {@link #softwareFallback} 이 켜져 있으면
|
||||||
|
* {@code crypto.subtle} 을 못 쓰는 환경에서도 클라이언트가 봉투를 만들기 때문이다. 다만 JS 를 끈
|
||||||
|
* 브라우저는 로그인하지 못하므로 {@code PERMISSIVE} 로 운영해 경고 로그를 지켜본 뒤 승격한다.</p>
|
||||||
|
*/
|
||||||
|
public enum PlaintextPolicy {
|
||||||
|
NONE, PERMISSIVE, ENFORCE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/** 키 보관 범위. */
|
||||||
|
private KeyScope keyScope = KeyScope.REQUEST;
|
||||||
|
|
||||||
|
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
|
||||||
|
private int keyTtlSeconds = 300;
|
||||||
|
|
||||||
|
/** 평문 비밀번호 파라미터 처리 정책. */
|
||||||
|
private PlaintextPolicy plaintextPolicy = PlaintextPolicy.NONE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code crypto.subtle} 을 쓸 수 없는 환경(원격 오리진 HTTP = 비 secure context)에서
|
||||||
|
* 순수 JS 구현(forge)으로 봉투를 만들지 여부.
|
||||||
|
*
|
||||||
|
* <p>forge 번들은 {@code crypto.subtle} 이 없을 때만 동적으로 내려가므로 HTTPS 운영 환경에서는
|
||||||
|
* 전송 바이트가 0 이다. 즉 이 값은 사실상 킬스위치다. 끄면 HTTP 개발환경은 평문으로 폴백한다
|
||||||
|
* ({@link #plaintextPolicy} 가 {@code ENFORCE} 면 로그인 불가).</p>
|
||||||
|
*/
|
||||||
|
private boolean softwareFallback = true;
|
||||||
|
|
||||||
|
/** RSA 키 길이. */
|
||||||
|
private int rsaKeySize = 2048;
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봉투 복호화 실패. 화면으로 스택을 올리지 않고 필터에서 흡수한다.
|
||||||
|
* 메시지에 평문이나 키 자료를 담지 않는다.
|
||||||
|
*/
|
||||||
|
public class PasswordDecryptException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public PasswordDecryptException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PasswordDecryptException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||||
|
|
||||||
|
import javax.servlet.Filter;
|
||||||
|
import javax.servlet.FilterChain;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.ServletRequest;
|
||||||
|
import javax.servlet.ServletResponse;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
|
||||||
|
*
|
||||||
|
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE + 2} 로 등록해
|
||||||
|
* MultipartFilter({@code MIN_VALUE + 1}) "이후", Lucy XSS 필터({@code MIN_VALUE + 3}) "이전"에
|
||||||
|
* 실행된다. 자세한 이유는 {@link DecryptingRequestWrapper} / {@link DecryptingMultipartRequestWrapper}
|
||||||
|
* 주석 참고 — 멀티파트(파일 업로드 동반) 요청은 MultipartFilter 가 만든
|
||||||
|
* {@code MultipartHttpServletRequest} 가 폼 필드를 자체 파싱해 반환하므로, 이 필터가 그보다
|
||||||
|
* 먼저 실행되면 비밀번호 파라미터가 복호화되지 않은 채로 컨트롤러까지 전달된다(법인가입처럼
|
||||||
|
* 파일 첨부와 비밀번호가 같은 폼에 있는 경우 재현).</p>
|
||||||
|
*
|
||||||
|
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
|
||||||
|
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>{@code /internal/**} 은 대상에서 제외한다.</b> playwright 전용 테스트 정리 API
|
||||||
|
* ({@code TestCleanupInternalController} 등)는 공유 토큰({@code X-Internal-Token}) + IP 허용목록으로
|
||||||
|
* 이미 인증되는 서버-to-서버 호출이라 브라우저 암호화 대상이 아니다({@code MenuAccessInterceptor} 가
|
||||||
|
* 같은 이유로 {@code /internal/**} 을 제외하는 것과 동일 패턴). 이 경로를 필터에 그대로 태우면, 도구가
|
||||||
|
* 보내는 평문 {@code password} 파라미터가 {@code plaintext-policy=ENFORCE} 설정에 걸려 빈 문자열로
|
||||||
|
* 치환되고 내부 API 는 "필수값 누락"으로 거부한다(예: {@code /internal/test-cleanup/password}).</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PasswordDecryptFilter implements Filter {
|
||||||
|
|
||||||
|
private static final String INTERNAL_API_PREFIX = "/internal/";
|
||||||
|
|
||||||
|
private final PasswordCryptoProperties properties;
|
||||||
|
private final PasswordEnvelopeCodec codec;
|
||||||
|
private final PasswordKeyStore keyStore;
|
||||||
|
|
||||||
|
public PasswordDecryptFilter(PasswordCryptoProperties properties,
|
||||||
|
PasswordEnvelopeCodec codec,
|
||||||
|
PasswordKeyStore keyStore) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.codec = codec;
|
||||||
|
this.keyStore = keyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||||
|
throws IOException, ServletException {
|
||||||
|
|
||||||
|
if (!properties.isEnabled() || !(request instanceof HttpServletRequest)) {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||||
|
if (!"POST".equalsIgnoreCase(httpRequest.getMethod()) || isInternalApiRequest(httpRequest)) {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DecryptingRequestWrapper wrapper = httpRequest instanceof MultipartHttpServletRequest
|
||||||
|
? new DecryptingMultipartRequestWrapper((MultipartHttpServletRequest) httpRequest, codec, keyStore, properties)
|
||||||
|
: new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
|
||||||
|
try {
|
||||||
|
chain.doFilter(wrapper, response);
|
||||||
|
} finally {
|
||||||
|
wrapper.consumeUsedKeys();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isInternalApiRequest(HttpServletRequest request) {
|
||||||
|
String path = request.getRequestURI().substring(request.getContextPath().length());
|
||||||
|
return path.startsWith(INTERNAL_API_PREFIX);
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.OAEPParameterSpec;
|
||||||
|
import javax.crypto.spec.PSource;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.Key;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.spec.MGF1ParameterSpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트가 만든 봉투(envelope) 문자열의 파싱·복호화.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(AES-GCM 암호문+태그)>
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>세그먼트는 base64<b>url</b>(패딩 없음)이라 Lucy XSS 이스케이프와 urlencode 를 모두 통과해도
|
||||||
|
* 값이 변형되지 않는다.</p>
|
||||||
|
*
|
||||||
|
* <p>RSA-OAEP 는 반드시 MGF1 해시까지 SHA-256 으로 지정해야 한다. SunJCE 는
|
||||||
|
* {@code OAEPWithSHA-256AndMGF1Padding} 만 지정하면 MGF1 에 SHA-1 을 쓰는데,
|
||||||
|
* 브라우저 Web Crypto 의 {@code RSA-OAEP + SHA-256} 은 MGF1 도 SHA-256 이라 그대로 두면
|
||||||
|
* 복호화가 실패한다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PasswordEnvelopeCodec {
|
||||||
|
|
||||||
|
public static final String PREFIX = "ENC1.";
|
||||||
|
|
||||||
|
/** AES-GCM 인증 태그 길이(비트). Web Crypto 기본값과 동일. */
|
||||||
|
private static final int GCM_TAG_BITS = 128;
|
||||||
|
|
||||||
|
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
|
||||||
|
|
||||||
|
/** 값이 봉투 형식인지. 파싱 비용 없이 접두사만 본다. */
|
||||||
|
public boolean isEnvelope(String value) {
|
||||||
|
return value != null && value.startsWith(PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 봉투에서 keyId 만 추출. 형식이 어긋나면 {@code null}. */
|
||||||
|
public String keyIdOf(String value) {
|
||||||
|
String[] parts = split(value);
|
||||||
|
return parts == null ? null : parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 개인키로 AES 키를 풀고 AES-GCM 으로 본문을 복호화한다.
|
||||||
|
*
|
||||||
|
* @throws PasswordDecryptException 형식 오류·키 불일치·태그 검증 실패
|
||||||
|
*/
|
||||||
|
public String decrypt(String value, PrivateKey privateKey) {
|
||||||
|
String[] parts = split(value);
|
||||||
|
if (parts == null) {
|
||||||
|
throw new PasswordDecryptException("봉투 형식이 올바르지 않다");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Key aesKey = unwrapAesKey(URL_DECODER.decode(parts[2]), privateKey);
|
||||||
|
byte[] iv = URL_DECODER.decode(parts[3]);
|
||||||
|
byte[] cipherText = URL_DECODER.decode(parts[4]);
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||||
|
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
|
||||||
|
} catch (PasswordDecryptException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new PasswordDecryptException("봉투 복호화 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Key unwrapAesKey(byte[] wrapped, PrivateKey privateKey) {
|
||||||
|
try {
|
||||||
|
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
|
||||||
|
rsa.init(Cipher.DECRYPT_MODE, privateKey, new OAEPParameterSpec(
|
||||||
|
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
|
||||||
|
return new SecretKeySpec(rsa.doFinal(wrapped), "AES");
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new PasswordDecryptException("AES 키 언랩 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code ENC1.keyId.encKey.iv.cipher} 5개 세그먼트. 어긋나면 null. */
|
||||||
|
private String[] split(String value) {
|
||||||
|
if (!isEnvelope(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] parts = value.split("\\.", 5);
|
||||||
|
if (parts.length != 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String part : parts) {
|
||||||
|
if (part.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 전송암호화용 공개키 발급.
|
||||||
|
*
|
||||||
|
* <p>경로를 {@code /api/**} 아래에 둔 이유: {@code PortalConfigWebDispatcherServlet#addInterceptors}
|
||||||
|
* 가 {@code /api/**} 를 인터셉터(비밀번호 변경 강제·step-up 가드)에서 제외한다. 로그인 전 익명 상태에서
|
||||||
|
* 호출되므로 {@code /api/session/csrf} 와 같은 자리에 있어야 리다이렉트에 걸리지 않는다.</p>
|
||||||
|
*
|
||||||
|
* <p>GET 이라 CSRF 대상이 아니고, 공개키만 나가므로 인증도 요구하지 않는다.</p>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/security")
|
||||||
|
public class PasswordKeyController {
|
||||||
|
|
||||||
|
private final PasswordCryptoProperties properties;
|
||||||
|
private final PasswordKeyStore keyStore;
|
||||||
|
|
||||||
|
public PasswordKeyController(PasswordCryptoProperties properties, PasswordKeyStore keyStore) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.keyStore = keyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/password-key.json")
|
||||||
|
public ResponseEntity<Map<String, Object>> issue(HttpServletRequest request) {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
// 클라이언트가 조용히 평문 폴백하도록 200 + enabled:false 로 답한다.
|
||||||
|
return noStore().body(Collections.<String, Object>singletonMap("enabled", Boolean.FALSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
IssuedKey key = keyStore.issue(request);
|
||||||
|
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||||
|
body.put("enabled", Boolean.TRUE);
|
||||||
|
body.put("alg", "RSA-OAEP-256");
|
||||||
|
body.put("keyId", key.getKeyId());
|
||||||
|
body.put("publicKey", key.getPublicKey());
|
||||||
|
body.put("expiresIn", key.getExpiresIn());
|
||||||
|
// REQUEST 는 1회용이라 클라이언트가 캐시하면 안 된다. 나머지는 만료까지 재사용해
|
||||||
|
// 비밀번호 검증용 ajax 가 호출마다 RSA 키쌍을 만들게 하지 않는다.
|
||||||
|
body.put("keyScope", properties.getKeyScope().name());
|
||||||
|
return noStore().body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity.BodyBuilder noStore() {
|
||||||
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore());
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA 키쌍 생성/인코딩 헬퍼. 키 보관소 구현들이 공유한다.
|
||||||
|
*/
|
||||||
|
final class PasswordKeyPairs {
|
||||||
|
|
||||||
|
private PasswordKeyPairs() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static KeyPair generate(int keySize) {
|
||||||
|
try {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(keySize);
|
||||||
|
return generator.generateKeyPair();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("RSA 키쌍 생성 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String newKeyId() {
|
||||||
|
return UUID.randomUUID().toString().replace("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 브라우저 {@code crypto.subtle.importKey('spki', ...)} 가 그대로 먹는 형식. */
|
||||||
|
static String toSpkiBase64(PublicKey publicKey) {
|
||||||
|
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKCS#8 바이트 → PrivateKey. 세션 복제(직렬화) 환경을 고려해
|
||||||
|
* {@link SessionScopedPasswordKeyStore} 는 키 객체 대신 바이트를 보관한다.
|
||||||
|
*/
|
||||||
|
static PrivateKey toPrivateKey(byte[] pkcs8) {
|
||||||
|
try {
|
||||||
|
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||||
|
throw new IllegalStateException("RSA 개인키 복원 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 전송암호화용 RSA 키쌍 보관소. 구현체는 {@link PasswordCryptoProperties.KeyScope} 별로 존재하며
|
||||||
|
* {@link PasswordKeyStoreRouter} 가 설정값에 따라 위임한다.
|
||||||
|
*/
|
||||||
|
public interface PasswordKeyStore {
|
||||||
|
|
||||||
|
/** 새 공개키를 발급한다(구현에 따라 기존 키 재사용). */
|
||||||
|
IssuedKey issue(HttpServletRequest request);
|
||||||
|
|
||||||
|
/** keyId 에 대응하는 개인키. 없거나 만료됐으면 {@code null}. */
|
||||||
|
PrivateKey resolve(String keyId, HttpServletRequest request);
|
||||||
|
|
||||||
|
/** 요청 처리가 끝난 뒤 호출. 1회용 키를 폐기하는 구현에서만 의미가 있다. */
|
||||||
|
void consume(String keyId, HttpServletRequest request);
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code portal.security.password-encrypt.key-scope} 설정값에 따라 실제 보관소로 위임한다.
|
||||||
|
* 설정은 yml 이므로 기동 시점에 고정된다(런타임 변경 시 재기동 필요).
|
||||||
|
*/
|
||||||
|
@Primary
|
||||||
|
@Component
|
||||||
|
public class PasswordKeyStoreRouter implements PasswordKeyStore {
|
||||||
|
|
||||||
|
private final PasswordCryptoProperties properties;
|
||||||
|
private final RequestScopedPasswordKeyStore requestScoped;
|
||||||
|
private final SessionScopedPasswordKeyStore sessionScoped;
|
||||||
|
private final ServerScopedPasswordKeyStore serverScoped;
|
||||||
|
|
||||||
|
public PasswordKeyStoreRouter(PasswordCryptoProperties properties,
|
||||||
|
RequestScopedPasswordKeyStore requestScoped,
|
||||||
|
SessionScopedPasswordKeyStore sessionScoped,
|
||||||
|
ServerScopedPasswordKeyStore serverScoped) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.requestScoped = requestScoped;
|
||||||
|
this.sessionScoped = sessionScoped;
|
||||||
|
this.serverScoped = serverScoped;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IssuedKey issue(HttpServletRequest request) {
|
||||||
|
return delegate().issue(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||||
|
return delegate().resolve(keyId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void consume(String keyId, HttpServletRequest request) {
|
||||||
|
delegate().consume(keyId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PasswordKeyStore delegate() {
|
||||||
|
PasswordCryptoProperties.KeyScope scope = properties.getKeyScope();
|
||||||
|
if (scope == PasswordCryptoProperties.KeyScope.SESSION) {
|
||||||
|
return sessionScoped;
|
||||||
|
}
|
||||||
|
if (scope == PasswordCryptoProperties.KeyScope.SERVER) {
|
||||||
|
return serverScoped;
|
||||||
|
}
|
||||||
|
return requestScoped;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 계열 파라미터명 판별. {@code plaintext-policy} 가 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
|
||||||
|
*
|
||||||
|
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
|
||||||
|
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
|
||||||
|
* 향후 축약형이 생길 것을 대비해 {@code passwd}/{@code pwd} 도 함께 본다.</p>
|
||||||
|
*/
|
||||||
|
final class PasswordParamNames {
|
||||||
|
|
||||||
|
private PasswordParamNames() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isPasswordLike(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lower = name.toLowerCase();
|
||||||
|
return lower.contains("password") || lower.contains("passwd") || lower.contains("pwd");
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기하는 보관소({@code key-scope: REQUEST}).
|
||||||
|
*
|
||||||
|
* <p>같은 keyId 로 두 번 복호화할 수 없으므로 재전송(replay) 공격이 차단된다. 개인키가 인스턴스
|
||||||
|
* 로컬 메모리에만 있으므로 WebLogic 다중 인스턴스에서는 스티키 세션이 전제다(현재 CSRF·단일세션
|
||||||
|
* 강제가 이미 세션 고정을 전제한다).</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RequestScopedPasswordKeyStore implements PasswordKeyStore {
|
||||||
|
|
||||||
|
/** 메모리 폭주 방지 상한. 초과 시 만료 스윕 후에도 남으면 발급을 거절하지 않고 가장 오래된 것부터 버린다. */
|
||||||
|
private static final int MAX_ENTRIES = 20_000;
|
||||||
|
|
||||||
|
private final PasswordCryptoProperties properties;
|
||||||
|
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public RequestScopedPasswordKeyStore(PasswordCryptoProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IssuedKey issue(HttpServletRequest request) {
|
||||||
|
sweep();
|
||||||
|
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
|
||||||
|
String keyId = PasswordKeyPairs.newKeyId();
|
||||||
|
int ttl = properties.getKeyTtlSeconds();
|
||||||
|
entries.put(keyId, new Entry(keyPair.getPrivate(), System.currentTimeMillis() + ttl * 1000L));
|
||||||
|
return new IssuedKey(keyId, PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()), ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||||
|
Entry entry = entries.get(keyId);
|
||||||
|
if (entry == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entry.expiresAt < System.currentTimeMillis()) {
|
||||||
|
entries.remove(keyId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void consume(String keyId, HttpServletRequest request) {
|
||||||
|
entries.remove(keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 만료 항목 정리. 발급 시점에만 돌리므로 별도 스케줄러가 필요 없다. */
|
||||||
|
private void sweep() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Iterator<Map.Entry<String, Entry>> it = entries.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
if (it.next().getValue().expiresAt < now) {
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entries.size() >= MAX_ENTRIES) {
|
||||||
|
log.warn("비밀번호 전송암호화 키 보관소 상한 초과 - size={}, 오래된 항목을 버린다", entries.size());
|
||||||
|
Iterator<Map.Entry<String, Entry>> overflow = entries.entrySet().iterator();
|
||||||
|
while (overflow.hasNext() && entries.size() >= MAX_ENTRIES) {
|
||||||
|
overflow.next();
|
||||||
|
overflow.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Entry {
|
||||||
|
private final PrivateKey privateKey;
|
||||||
|
private final long expiresAt;
|
||||||
|
|
||||||
|
private Entry(PrivateKey privateKey, long expiresAt) {
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청이 TLS 구간으로 들어왔는지 판정한다. 경고 로그 문구를 가르는 용도다.
|
||||||
|
*
|
||||||
|
* <p>{@code request.isSecure()} 만 봐서는 안 된다. {@code PasswordDecryptFilter} 는 Lucy XSS 필터보다
|
||||||
|
* 먼저 돌아야 해서 order 가 {@code Integer.MIN_VALUE} 인데, {@code ForwardedHeaderFilter}
|
||||||
|
* ({@code PortalConfigForwardedHeader}) 도 {@code Ordered.HIGHEST_PRECEDENCE} = 같은 값이라
|
||||||
|
* 둘의 상대 순서가 보장되지 않는다. 즉 이 시점의 {@code isSecure()} 는 {@code X-Forwarded-Proto}
|
||||||
|
* 교정 <b>전</b> 값일 수 있다.</p>
|
||||||
|
*
|
||||||
|
* <p>그래서 헤더를 직접 본다. 앞단 프록시가 신뢰 경계 안이라는 전제는 {@code ForwardedHeaderFilter} 와 같다.</p>
|
||||||
|
*/
|
||||||
|
final class RequestTransport {
|
||||||
|
|
||||||
|
private RequestTransport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isSecure(HttpServletRequest request) {
|
||||||
|
String proto = request.getHeader("X-Forwarded-Proto");
|
||||||
|
if (proto != null && !proto.isEmpty()) {
|
||||||
|
// 프록시가 여러 단이면 "https, http" 처럼 쌓인다. 클라이언트에 가장 가까운 첫 값이 기준이다.
|
||||||
|
int comma = proto.indexOf(',');
|
||||||
|
String first = (comma >= 0 ? proto.substring(0, comma) : proto).trim();
|
||||||
|
return "https".equalsIgnoreCase(first);
|
||||||
|
}
|
||||||
|
return request.isSecure();
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 고정 키쌍 + TTL 로테이션 구현({@code key-scope: SERVER}).
|
||||||
|
*
|
||||||
|
* <p>직전 키를 1개 유예 보관해, 로테이션 순간에 이미 공개키를 받아 간 폼이 제출돼도 복호화된다.
|
||||||
|
* 재전송 방지 수단은 없으므로(같은 봉투를 여러 번 보내도 복호화됨) 인증 실패횟수 제한·계정 잠금에
|
||||||
|
* 의존한다. 무상태라 다중 인스턴스에서도 각자 동작하지만, 인스턴스마다 키가 달라
|
||||||
|
* 공개키 발급과 폼 제출이 같은 인스턴스로 가야 한다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ServerScopedPasswordKeyStore implements PasswordKeyStore {
|
||||||
|
|
||||||
|
private final PasswordCryptoProperties properties;
|
||||||
|
|
||||||
|
private volatile Holder current;
|
||||||
|
private volatile Holder previous;
|
||||||
|
|
||||||
|
public ServerScopedPasswordKeyStore(PasswordCryptoProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IssuedKey issue(HttpServletRequest request) {
|
||||||
|
Holder holder = currentHolder();
|
||||||
|
int remaining = (int) Math.max(1, (holder.expiresAt - System.currentTimeMillis()) / 1000L);
|
||||||
|
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||||
|
Holder holder = current;
|
||||||
|
if (holder != null && holder.keyId.equals(keyId)) {
|
||||||
|
return holder.privateKey;
|
||||||
|
}
|
||||||
|
Holder old = previous;
|
||||||
|
if (old != null && old.keyId.equals(keyId)) {
|
||||||
|
return old.privateKey;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void consume(String keyId, HttpServletRequest request) {
|
||||||
|
// 고정 키라 폐기하지 않는다.
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized Holder currentHolder() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (current == null || current.expiresAt < now) {
|
||||||
|
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
|
||||||
|
previous = current;
|
||||||
|
current = new Holder(
|
||||||
|
PasswordKeyPairs.newKeyId(),
|
||||||
|
keyPair.getPrivate(),
|
||||||
|
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
|
||||||
|
now + properties.getKeyTtlSeconds() * 1000L);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Holder {
|
||||||
|
private final String keyId;
|
||||||
|
private final PrivateKey privateKey;
|
||||||
|
private final String publicKeySpki;
|
||||||
|
private final long expiresAt;
|
||||||
|
|
||||||
|
private Holder(String keyId, PrivateKey privateKey, String publicKeySpki, long expiresAt) {
|
||||||
|
this.keyId = keyId;
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
this.publicKeySpki = publicKeySpki;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 단위로 키쌍을 보관하는 구현({@code key-scope: SESSION}).
|
||||||
|
*
|
||||||
|
* <p>개인키를 객체가 아니라 PKCS#8 바이트로 들고 있어 세션 복제(직렬화)에도 안전하다.
|
||||||
|
* 같은 keyId 가 세션 수명 동안 재사용되므로 재전송 방지는 IV 랜덤성과 애플리케이션의
|
||||||
|
* 인증 실패횟수 제한에 의존한다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SessionScopedPasswordKeyStore implements PasswordKeyStore {
|
||||||
|
|
||||||
|
private static final String SESSION_ATTR = "DJB_PWD_CRYPTO_KEY";
|
||||||
|
|
||||||
|
private final PasswordCryptoProperties properties;
|
||||||
|
|
||||||
|
public SessionScopedPasswordKeyStore(PasswordCryptoProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IssuedKey issue(HttpServletRequest request) {
|
||||||
|
HttpSession session = request.getSession(true);
|
||||||
|
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (holder == null || holder.expiresAt < now) {
|
||||||
|
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
|
||||||
|
holder = new Holder(
|
||||||
|
PasswordKeyPairs.newKeyId(),
|
||||||
|
keyPair.getPrivate().getEncoded(),
|
||||||
|
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
|
||||||
|
now + properties.getKeyTtlSeconds() * 1000L);
|
||||||
|
session.setAttribute(SESSION_ATTR, holder);
|
||||||
|
}
|
||||||
|
int remaining = (int) Math.max(1, (holder.expiresAt - now) / 1000L);
|
||||||
|
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrivateKey resolve(String keyId, HttpServletRequest request) {
|
||||||
|
HttpSession session = request.getSession(false);
|
||||||
|
if (session == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
|
||||||
|
if (holder == null || !holder.keyId.equals(keyId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (holder.expiresAt < System.currentTimeMillis()) {
|
||||||
|
session.removeAttribute(SESSION_ATTR);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PasswordKeyPairs.toPrivateKey(holder.privateKeyPkcs8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void consume(String keyId, HttpServletRequest request) {
|
||||||
|
// 세션 수명 동안 재사용한다.
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Holder implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final String keyId;
|
||||||
|
private final byte[] privateKeyPkcs8;
|
||||||
|
private final String publicKeySpki;
|
||||||
|
private final long expiresAt;
|
||||||
|
|
||||||
|
private Holder(String keyId, byte[] privateKeyPkcs8, String publicKeySpki, long expiresAt) {
|
||||||
|
this.keyId = keyId;
|
||||||
|
this.privateKeyPkcs8 = privateKeyPkcs8;
|
||||||
|
this.publicKeySpki = publicKeySpki;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,5 @@
|
|||||||
package com.eactive.apim.portal.common.util;
|
package com.eactive.apim.portal.common.util;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Base64;
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component("encryptionUtil")
|
|
||||||
public class EncryptionUtil {
|
public class EncryptionUtil {
|
||||||
|
|
||||||
public static String generateNewPassword() {
|
public static String generateNewPassword() {
|
||||||
@@ -53,40 +40,4 @@ public class EncryptionUtil {
|
|||||||
return newpassword.toString();
|
return newpassword.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
|
||||||
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
|
|
||||||
|
|
||||||
private static final String ALGORITHM = "AES";
|
|
||||||
|
|
||||||
private SecretKeySpec createSecretKey() {
|
|
||||||
byte[] key = secretKey.getBytes(StandardCharsets.UTF_8);
|
|
||||||
return new SecretKeySpec(key, ALGORITHM);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
|
||||||
if (value == null || value.isEmpty()) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
SecretKeySpec key = createSecretKey();
|
|
||||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
|
||||||
|
|
||||||
byte[] encryptedBytes = cipher.doFinal(value.getBytes());
|
|
||||||
return Base64.getEncoder().encodeToString(encryptedBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String decrypt(String encrypted) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
|
||||||
if (encrypted == null || encrypted.isEmpty()) {
|
|
||||||
return encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
SecretKeySpec key = createSecretKey();
|
|
||||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
|
||||||
|
|
||||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encrypted));
|
|
||||||
return new String(decryptedBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,50 @@ public class StringMaskingUtil {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */
|
||||||
|
private static final String[] SENSITIVE_PARAM_KEYWORDS = {
|
||||||
|
"password", "passwd", "pwd", "secret", "credential", "token"};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다.
|
||||||
|
*
|
||||||
|
* <p>비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로,
|
||||||
|
* 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.</p>
|
||||||
|
*
|
||||||
|
* <pre>id=[user@a.com], password=[****]</pre>
|
||||||
|
*/
|
||||||
|
public static String maskParameterMap(java.util.Map<String, String[]> parameterMap) {
|
||||||
|
if (parameterMap == null || parameterMap.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
sb.append(entry.getKey()).append('=');
|
||||||
|
if (isSensitiveParamName(entry.getKey())) {
|
||||||
|
sb.append("[****]");
|
||||||
|
} else {
|
||||||
|
sb.append(Arrays.toString(entry.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSensitiveParamName(String name) {
|
||||||
|
if (!isValidString(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lower = name.toLowerCase();
|
||||||
|
for (String keyword : SENSITIVE_PARAM_KEYWORDS) {
|
||||||
|
if (lower.contains(keyword)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
||||||
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
||||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.eactive.apim.portal.common.validator;
|
package com.eactive.apim.portal.common.validator;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
|
||||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import org.apache.commons.beanutils.PropertyUtils;
|
import org.apache.commons.beanutils.PropertyUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
import javax.validation.ConstraintValidator;
|
import javax.validation.ConstraintValidator;
|
||||||
import javax.validation.ConstraintValidatorContext;
|
import javax.validation.ConstraintValidatorContext;
|
||||||
@@ -26,6 +28,23 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
// 공백 문자 정규식
|
// 공백 문자 정규식
|
||||||
private static final String BLANKPT = "(\\s)";
|
private static final String BLANKPT = "(\\s)";
|
||||||
|
|
||||||
|
/** 키보드 배열 인접 판정 기준 길이 (기존 연속 문자/숫자 규칙과 동일하게 3자) */
|
||||||
|
private static final int KEYBOARD_SEQUENCE_LENGTH = 3;
|
||||||
|
/** 키보드 배열 행. 대문자 기준으로 비교한다. */
|
||||||
|
private static final String[] KEYBOARD_ROWS = {
|
||||||
|
"QWERTYUIOP",
|
||||||
|
"ASDFGHJKL",
|
||||||
|
"ZXCVBNM",
|
||||||
|
"1234567890"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키보드 연속 문자 차단 여부. Spring 이 관리하는 경로(LocalValidatorFactoryBean)에서만 주입되며,
|
||||||
|
* 순수 {@code new} 로 생성된 경우 null 이라 기본값(차단)으로 동작한다.
|
||||||
|
*/
|
||||||
|
@Autowired(required = false)
|
||||||
|
private PasswordPolicyProperties passwordPolicyProperties;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void initialize(PasswordRule constraintAnnotation) {
|
public void initialize(PasswordRule constraintAnnotation) {
|
||||||
this.password = constraintAnnotation.password();
|
this.password = constraintAnnotation.password();
|
||||||
@@ -48,10 +67,21 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
return isValid(passwordValue, loginIdValue, mobileNumberValue, keyboardSequenceBlocked());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 주입이 안 된 경우(순수 new)에는 기본값인 차단으로 동작한다. */
|
||||||
|
private boolean keyboardSequenceBlocked() {
|
||||||
|
return passwordPolicyProperties == null
|
||||||
|
? PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK
|
||||||
|
: passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||||
|
return isValid(password, loginId, mobileNumber, PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid(String password, String loginId, String mobileNumber, boolean blockKeyboardSequence) {
|
||||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||||
|
|
||||||
@@ -81,6 +111,11 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 키보드 배열 연속 문자 체크 (PortalProperty 로 on/off)
|
||||||
|
if (blockKeyboardSequence && hasKeyboardSequence(password)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 공백 체크
|
// 공백 체크
|
||||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
@@ -118,6 +153,29 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키보드 배열상 인접한 문자가 {@value #KEYBOARD_SEQUENCE_LENGTH} 자 이상 이어지는지 (정·역방향 모두).
|
||||||
|
*
|
||||||
|
* <p>같은 행(row) 안에서 좌우로 이어지는 경우만 본다. 예) {@code qwe}, {@code asd}, {@code trewq}.
|
||||||
|
* 클라이언트 {@code static/js/password-policy.js} 의 {@code hasKeyboardSequence} 와 동일 규칙이어야 한다.</p>
|
||||||
|
*/
|
||||||
|
public static boolean hasKeyboardSequence(String password) {
|
||||||
|
if (password == null || password.length() < KEYBOARD_SEQUENCE_LENGTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String tmpPw = password.toUpperCase();
|
||||||
|
for (int i = 0; i <= tmpPw.length() - KEYBOARD_SEQUENCE_LENGTH; i++) {
|
||||||
|
String chunk = tmpPw.substring(i, i + KEYBOARD_SEQUENCE_LENGTH);
|
||||||
|
String reversed = new StringBuilder(chunk).reverse().toString();
|
||||||
|
for (String row : KEYBOARD_ROWS) {
|
||||||
|
if (row.contains(chunk) || row.contains(reversed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
|
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
|
||||||
public static boolean containsLoginIdLocalPart(String password, String loginId) {
|
public static boolean containsLoginIdLocalPart(String password, String loginId) {
|
||||||
if (password == null || loginId == null || loginId.isEmpty()) {
|
if (password == null || loginId == null || loginId.isEmpty()) {
|
||||||
|
|||||||
+8
-17
@@ -4,16 +4,13 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants;
|
|||||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||||
|
import com.eactive.apim.portal.common.security.AccountLockService;
|
||||||
import com.eactive.apim.portal.common.security.LoginLockPolicy;
|
import com.eactive.apim.portal.common.security.LoginLockPolicy;
|
||||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import org.apache.groovy.util.Maps;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
@@ -47,18 +44,18 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
|||||||
|
|
||||||
private final PortalUserRepository portalUserRepository;
|
private final PortalUserRepository portalUserRepository;
|
||||||
private final PortalUserLogService userLogService;
|
private final PortalUserLogService userLogService;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final LoginLockPolicy loginLockPolicy;
|
private final LoginLockPolicy loginLockPolicy;
|
||||||
|
private final AccountLockService accountLockService;
|
||||||
|
|
||||||
|
|
||||||
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
||||||
PortalUserLogService userLogService,
|
PortalUserLogService userLogService,
|
||||||
MessageHandlerService messageHandlerService,
|
LoginLockPolicy loginLockPolicy,
|
||||||
LoginLockPolicy loginLockPolicy) {
|
AccountLockService accountLockService) {
|
||||||
this.portalUserRepository = portalUserRepository;
|
this.portalUserRepository = portalUserRepository;
|
||||||
this.userLogService = userLogService;
|
this.userLogService = userLogService;
|
||||||
this.messageHandlerService = messageHandlerService;
|
|
||||||
this.loginLockPolicy = loginLockPolicy;
|
this.loginLockPolicy = loginLockPolicy;
|
||||||
|
this.accountLockService = accountLockService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -79,16 +76,10 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
|||||||
|
|
||||||
int lockCount = loginLockPolicy.lockCount();
|
int lockCount = loginLockPolicy.lockCount();
|
||||||
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
||||||
if (user.getLoginFailureCount() >= lockCount) {
|
|
||||||
user.setAccountLockYn("Y");
|
|
||||||
|
|
||||||
// 계정 잠금 알림
|
|
||||||
messageHandlerService.publishEvent(
|
|
||||||
MessageCode.USER_ACCOUNT_LOCKED,
|
|
||||||
MessageRecipient.of(user),
|
|
||||||
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
|
|
||||||
}
|
|
||||||
portalUserRepository.save(user);
|
portalUserRepository.save(user);
|
||||||
|
if (user.getLoginFailureCount() >= lockCount) {
|
||||||
|
accountLockService.lock(user, lockCount + "회 이상 로그인 실패로 인한 계정 잠금");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.eactive.apim.portal.config;
|
|||||||
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||||
|
import com.eactive.apim.portal.common.security.passwordcrypto.DecryptingMultipartRequestWrapper;
|
||||||
|
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
|
||||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
@@ -64,12 +66,41 @@ public class PortalConfigSecurity {
|
|||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 전송암호화 복호화 필터.
|
||||||
|
*
|
||||||
|
* <p>order {@code MIN_VALUE + 2} — {@code PortalConfigWebDispatcherServlet} 의
|
||||||
|
* MultipartFilter({@code MIN_VALUE + 1}) <b>이후</b>, 아래 Lucy XSS 필터
|
||||||
|
* ({@code MIN_VALUE + 3}) <b>이전</b>에 실행되어야 한다.</p>
|
||||||
|
*
|
||||||
|
* <p>MultipartFilter 이후여야 하는 이유: 파일 업로드가 동반된 폼(예: 법인가입의 사업자등록증
|
||||||
|
* 첨부)은 {@code multipart/form-data} 로 제출되는데, Spring 의 {@code MultipartHttpServletRequest}
|
||||||
|
* 는 폼 필드 값을 자체 파싱해 {@code getParameter()} 에서 곧바로 반환하고 감싸고 있는 원본 요청으로
|
||||||
|
* 위임하지 않는다. 이 필터가 MultipartFilter 보다 먼저 실행되어(즉 더 안쪽에서) 요청을 감싸면,
|
||||||
|
* 멀티파트 안의 비밀번호 파라미터는 이 필터의 복호화 로직을 거치지 않고 봉투(ENC1) 문자열 그대로
|
||||||
|
* 컨트롤러까지 전달된다({@link DecryptingMultipartRequestWrapper} 주석 참고).</p>
|
||||||
|
*
|
||||||
|
* <p>Lucy XSS 필터 이전이어야 하는 이유: 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야
|
||||||
|
* 특수문자가 든 비밀번호의 해시 비교 결과가 지금과 동일하게 유지된다. 이 순서를 뒤집으면 기존
|
||||||
|
* 계정 로그인이 깨진다.</p>
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
|
||||||
|
PasswordDecryptFilter filter) {
|
||||||
|
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
|
||||||
|
registrationBean.setOrder(Integer.MIN_VALUE + 2);
|
||||||
|
registrationBean.addUrlPatterns("/*");
|
||||||
|
return registrationBean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야
|
||||||
|
// 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐).
|
||||||
@Bean
|
@Bean
|
||||||
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
||||||
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
||||||
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
||||||
registrationBean.setFilter(xssEscapeServletFilter);
|
registrationBean.setFilter(xssEscapeServletFilter);
|
||||||
registrationBean.setOrder(Integer.MIN_VALUE + 1);
|
registrationBean.setOrder(Integer.MIN_VALUE + 3);
|
||||||
registrationBean.addUrlPatterns("/*");
|
registrationBean.addUrlPatterns("/*");
|
||||||
return registrationBean;
|
return registrationBean;
|
||||||
}
|
}
|
||||||
@@ -132,7 +163,10 @@ public class PortalConfigSecurity {
|
|||||||
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
|
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
|
||||||
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
|
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
|
||||||
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
|
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
// /internal/test-cleanup 도 동일 이유(Playwright 등 세션 없는 호출) + 동일 통제 방식.
|
||||||
|
.ignoringRequestMatchers(
|
||||||
|
new AntPathRequestMatcher("/internal/menu/**"),
|
||||||
|
new AntPathRequestMatcher("/internal/test-cleanup/**"))
|
||||||
)
|
)
|
||||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||||
|
|||||||
+39
-20
@@ -57,6 +57,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
@Value("${app.resource-caching.enabled:false}")
|
@Value("${app.resource-caching.enabled:false}")
|
||||||
private boolean resourceCachingEnabled;
|
private boolean resourceCachingEnabled;
|
||||||
|
|
||||||
|
// 정적자원 서빙 루트(application.yml: app.web-resources.static-base).
|
||||||
|
// 기본은 classpath(빌드 산출물), local_rinjaemac 프로파일은 file:${user.dir}/src/main/resources/static/
|
||||||
|
// 로 오버라이드해 소스 편집이 재빌드 없이 즉시 반영되도록 한다.
|
||||||
|
@Value("${app.web-resources.static-base:classpath:/static/}")
|
||||||
|
private String staticBase;
|
||||||
|
|
||||||
public PortalConfigWebDispatcherServlet(Environment environment,
|
public PortalConfigWebDispatcherServlet(Environment environment,
|
||||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
||||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
|
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
|
||||||
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
|
addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
|
||||||
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
|
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
|
||||||
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
|
addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
|
||||||
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
|
addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
|
||||||
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
|
addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
|
||||||
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
|
addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
|
||||||
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
|
addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
|
||||||
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
|
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
|
||||||
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
|
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||||
|
*
|
||||||
|
* <p>버전닝이 꺼져 있으면(prod 포함 — {@link #isResourceVersioningEnabled()} 참고) URL 이
|
||||||
|
* 고정이라 장기 {@code max-age} 캐시를 쓰면 배포 후에도 브라우저가 옛 내용을 계속 쓸 위험이 있다.
|
||||||
|
* 이 경우 {@code no-cache}(매 요청 {@code If-Modified-Since} 조건부 재검증, 변경 없으면 304)로
|
||||||
|
* 신선도를 보장한다 — OHS 가 정적 리소스를 직접 서빙해도 Apache 가 파일 mtime 기준으로 동일하게
|
||||||
|
* Last-Modified/조건부 GET 을 처리하므로 WAS 와 동작이 어긋나지 않는다
|
||||||
|
* (배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리).</p>
|
||||||
*/
|
*/
|
||||||
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
boolean versioningOn = isResourceVersioningEnabled();
|
||||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
CacheControl cacheControl;
|
||||||
CacheControl cacheControl = isResourceCachingEnabled()
|
if (!isResourceCachingEnabled()) {
|
||||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
|
||||||
: CacheControl.noStore();
|
cacheControl = CacheControl.noStore();
|
||||||
|
} else if (versioningOn) {
|
||||||
|
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
|
||||||
|
} else {
|
||||||
|
cacheControl = CacheControl.noCache();
|
||||||
|
}
|
||||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||||
.addResourceLocations(locations)
|
.addResourceLocations(locations)
|
||||||
.setCacheControl(cacheControl)
|
.setCacheControl(cacheControl)
|
||||||
.resourceChain(isResourceCachingEnabled());
|
.resourceChain(isResourceCachingEnabled());
|
||||||
if (isResourceVersioningEnabled()) {
|
if (versioningOn) {
|
||||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||||
}
|
}
|
||||||
chain.addResolver(new PathResourceResolver());
|
chain.addResolver(new PathResourceResolver());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
|
* 정적자원 해시 버전닝 활성 여부. {@code app.resource-versioning.enabled} 값을 그대로 따른다.
|
||||||
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
|
*
|
||||||
|
* <p>과거 prod 프로파일은 이 토글과 무관하게 항상 ON 이었으나, OHS 가 정적 리소스를 직접
|
||||||
|
* 서빙하는 구조로 바뀌면서 OFF 로 전환했다 — 해시가 붙은 URL(main-<hash>.css)이 OHS
|
||||||
|
* 디스크의 무해시 원본과 어긋나 404 가 날 수 있어서다(prod.yml 에서 명시적으로 false 설정).
|
||||||
|
* 대신 캐시 신선도는 {@link #addStaticResourceHandler} 의 no-cache 조건부 재검증으로 보장한다.</p>
|
||||||
*/
|
*/
|
||||||
private boolean isResourceVersioningEnabled() {
|
private boolean isResourceVersioningEnabled() {
|
||||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return resourceVersioningEnabled;
|
return resourceVersioningEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
|
|||||||
public RestTemplate restTemplate() {
|
public RestTemplate restTemplate() {
|
||||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
|
||||||
// 연결 타임아웃: 10초
|
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
|
||||||
factory.setConnectTimeout(10000);
|
factory.setConnectTimeout(5000);
|
||||||
|
|
||||||
// 읽기 타임아웃: 30초
|
// 읽기 타임아웃: 5초
|
||||||
factory.setReadTimeout(30000);
|
factory.setReadTimeout(5000);
|
||||||
|
|
||||||
return new RestTemplate(factory);
|
return new RestTemplate(factory);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-6
@@ -27,13 +27,23 @@ import java.util.Optional;
|
|||||||
*
|
*
|
||||||
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
|
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
|
||||||
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
|
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
|
||||||
|
*
|
||||||
|
* <p>API 그룹으로 특정 법인에게만 공개된 API 의 이슈는, 그 API 를 조회할 수 없는 사용자에게는
|
||||||
|
* 이슈 자체(제목·요약·타임라인 포함)를 노출하지 않는다. 영향 API 가 하나도 없는 이슈(전사 공지성)는
|
||||||
|
* 그대로 노출한다. {@code visibleApiIds} 는 {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()}
|
||||||
|
* 로 구한, 현재 사용자에게 공개된 API ID 집합이다.</p>
|
||||||
*/
|
*/
|
||||||
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
|
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
|
||||||
|
|
||||||
String VISIBLE = " i.draftYn = 'N'"
|
String VISIBLE = " i.draftYn = 'N'"
|
||||||
+ " AND (i.noticeId IS NULL"
|
+ " AND (i.noticeId IS NULL"
|
||||||
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
|
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
|
||||||
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
|
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y'))"
|
||||||
|
+ " AND (NOT EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia"
|
||||||
|
+ " WHERE ia.incidentId = i.incidentId)"
|
||||||
|
+ " OR EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia2"
|
||||||
|
+ " WHERE ia2.incidentId = i.incidentId"
|
||||||
|
+ " AND ia2.apiId IN :visibleApiIds)) ";
|
||||||
|
|
||||||
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
|
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
|
||||||
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
|
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
|
||||||
@@ -55,7 +65,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
+ " AND i.state NOT IN :closedStates"
|
+ " AND i.state NOT IN :closedStates"
|
||||||
+ " ORDER BY i.startedAt DESC")
|
+ " ORDER BY i.startedAt DESC")
|
||||||
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
|
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
|
||||||
@Param("closedStates") Collection<IncidentState> closedStates);
|
@Param("closedStates") Collection<IncidentState> closedStates,
|
||||||
|
@Param("visibleApiIds") Collection<String> visibleApiIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
|
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
|
||||||
@@ -66,7 +77,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
|
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
|
||||||
+ " ORDER BY i.startedAt ASC")
|
+ " ORDER BY i.startedAt ASC")
|
||||||
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
|
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
|
||||||
@Param("now") LocalDateTime now);
|
@Param("now") LocalDateTime now,
|
||||||
|
@Param("visibleApiIds") Collection<String> visibleApiIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
|
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
|
||||||
@@ -80,6 +92,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
+ " AND " + CLOSED_CONDITION)
|
+ " AND " + CLOSED_CONDITION)
|
||||||
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
|
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
|
||||||
@Param("now") LocalDateTime now,
|
@Param("now") LocalDateTime now,
|
||||||
|
@Param("visibleApiIds") Collection<String> visibleApiIds,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,7 +105,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
|
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
|
||||||
+ " ORDER BY i.startedAt DESC")
|
+ " ORDER BY i.startedAt DESC")
|
||||||
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
|
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
|
||||||
@Param("to") LocalDateTime to);
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("visibleApiIds") Collection<String> visibleApiIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
|
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
|
||||||
@@ -106,7 +120,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
+ " ORDER BY i.startedAt DESC")
|
+ " ORDER BY i.startedAt DESC")
|
||||||
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
|
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
|
||||||
@Param("to") LocalDateTime to,
|
@Param("to") LocalDateTime to,
|
||||||
@Param("apiId") String apiId);
|
@Param("apiId") String apiId,
|
||||||
|
@Param("visibleApiIds") Collection<String> visibleApiIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 상세 (P7)
|
* 공개 상세 (P7)
|
||||||
@@ -114,5 +129,6 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
|
|||||||
@Query("SELECT i FROM DjbApistatusIncident i"
|
@Query("SELECT i FROM DjbApistatusIncident i"
|
||||||
+ " WHERE " + VISIBLE
|
+ " WHERE " + VISIBLE
|
||||||
+ " AND i.incidentId = :incidentId")
|
+ " AND i.incidentId = :incidentId")
|
||||||
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
|
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId,
|
||||||
|
@Param("visibleApiIds") Collection<String> visibleApiIds);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -152,7 +152,8 @@ public class ApiCurrentStatusService {
|
|||||||
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
|
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
|
||||||
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
|
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
|
||||||
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
|
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
|
||||||
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
|
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
|
||||||
|
catalogService.getVisibleApiIdsForQuery());
|
||||||
if (openIncidents.isEmpty()) {
|
if (openIncidents.isEmpty()) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
@@ -178,7 +179,8 @@ public class ApiCurrentStatusService {
|
|||||||
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
|
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
|
||||||
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
|
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
|
||||||
List<DjbApistatusIncident> maintenances = incidentQueryRepository
|
List<DjbApistatusIncident> maintenances = incidentQueryRepository
|
||||||
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
|
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now,
|
||||||
|
catalogService.getVisibleApiIdsForQuery()).stream()
|
||||||
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
|
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (maintenances.isEmpty()) {
|
if (maintenances.isEmpty()) {
|
||||||
@@ -208,7 +210,8 @@ public class ApiCurrentStatusService {
|
|||||||
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
|
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
|
||||||
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
|
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
|
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
|
||||||
|
catalogService.getVisibleApiIdsForQuery()).stream()
|
||||||
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (incidents.isEmpty()) {
|
if (incidents.isEmpty()) {
|
||||||
|
|||||||
+14
@@ -16,6 +16,7 @@ import javax.persistence.EntityManager;
|
|||||||
import javax.persistence.PersistenceContext;
|
import javax.persistence.PersistenceContext;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -99,6 +100,19 @@ public class ApiStatusCatalogService {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** IN 절 바인딩용 API ID 없을 리 없는 더미값. 실제 API ID 는 절대 이 값이 될 수 없다 */
|
||||||
|
private static final List<String> NO_VISIBLE_API = Collections.singletonList("__NO_VISIBLE_API__");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ApiStatusIncidentQueryRepository} 의 {@code :visibleApiIds} 바인딩용.
|
||||||
|
* Oracle 은 빈 컬렉션으로 {@code IN ()} 을 만들면 구문 오류가 나므로, 조회 가능한 API 가
|
||||||
|
* 하나도 없을 때는 절대 매치되지 않는 더미값으로 대체한다.
|
||||||
|
*/
|
||||||
|
public Collection<String> getVisibleApiIdsForQuery() {
|
||||||
|
Map<String, String> names = getVisibleApiNames();
|
||||||
|
return names.isEmpty() ? NO_VISIBLE_API : names.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
|
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
|
||||||
*
|
*
|
||||||
|
|||||||
+3
-2
@@ -130,9 +130,10 @@ public class ApiStatusIssueHistoryService {
|
|||||||
|
|
||||||
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
|
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
|
||||||
String apiId, String kind) {
|
String apiId, String kind) {
|
||||||
|
java.util.Collection<String> visibleApiIds = catalogService.getVisibleApiIdsForQuery();
|
||||||
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
|
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
|
||||||
? incidentQueryRepository.findVisibleOverlapping(from, to)
|
? incidentQueryRepository.findVisibleOverlapping(from, to, visibleApiIds)
|
||||||
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
|
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId, visibleApiIds);
|
||||||
|
|
||||||
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
|
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
|
||||||
if (filter == null) {
|
if (filter == null) {
|
||||||
|
|||||||
+7
-4
@@ -25,19 +25,22 @@ public class ApiStatusQueryService {
|
|||||||
|
|
||||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||||
private final ApiStatusAssembler assembler;
|
private final ApiStatusAssembler assembler;
|
||||||
|
private final ApiStatusCatalogService catalogService;
|
||||||
|
|
||||||
/** P3 - 진행 중 장애·지연 */
|
/** P3 - 진행 중 장애·지연 */
|
||||||
public List<ActiveIncidentDTO> getActiveIncidents() {
|
public List<ActiveIncidentDTO> getActiveIncidents() {
|
||||||
LocalDateTime now = ApiStatusSupport.now();
|
LocalDateTime now = ApiStatusSupport.now();
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
|
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
|
||||||
|
catalogService.getVisibleApiIdsForQuery());
|
||||||
return assembler.toActiveIncidents(incidents, now);
|
return assembler.toActiveIncidents(incidents, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P5 - 예정/진행 중 점검 */
|
/** P5 - 예정/진행 중 점검 */
|
||||||
public List<MaintenanceCardDTO> getOngoingMaintenance() {
|
public List<MaintenanceCardDTO> getOngoingMaintenance() {
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now());
|
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now(),
|
||||||
|
catalogService.getVisibleApiIdsForQuery());
|
||||||
return assembler.toMaintenanceCards(incidents);
|
return assembler.toMaintenanceCards(incidents);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,14 +49,14 @@ public class ApiStatusQueryService {
|
|||||||
int limit = size <= 0 ? 5 : Math.min(size, 50);
|
int limit = size <= 0 ? 5 : Math.min(size, 50);
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
|
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
|
||||||
PageRequest.of(0, limit))
|
catalogService.getVisibleApiIdsForQuery(), PageRequest.of(0, limit))
|
||||||
.getContent();
|
.getContent();
|
||||||
return assembler.toPastIssueCards(incidents);
|
return assembler.toPastIssueCards(incidents);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P7 - 이슈 공개 상세 */
|
/** P7 - 이슈 공개 상세 */
|
||||||
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
|
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
|
||||||
return incidentQueryRepository.findVisibleById(incidentId)
|
return incidentQueryRepository.findVisibleById(incidentId, catalogService.getVisibleApiIdsForQuery())
|
||||||
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
|
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -34,6 +34,7 @@ public class ApiStatusUptimeService {
|
|||||||
|
|
||||||
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
|
||||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||||
|
private final ApiStatusCatalogService catalogService;
|
||||||
|
|
||||||
/** P2 - 90일 가동률 */
|
/** P2 - 90일 가동률 */
|
||||||
public List<DailyStatDTO> getDailyStats(int days) {
|
public List<DailyStatDTO> getDailyStats(int days) {
|
||||||
@@ -43,7 +44,8 @@ public class ApiStatusUptimeService {
|
|||||||
LocalDate from = today.minusDays(windowDays - 1L);
|
LocalDate from = today.minusDays(windowDays - 1L);
|
||||||
|
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay());
|
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay(),
|
||||||
|
catalogService.getVisibleApiIdsForQuery());
|
||||||
|
|
||||||
List<DailyStatDTO> result = new ArrayList<>();
|
List<DailyStatDTO> result = new ArrayList<>();
|
||||||
for (int offset = 0; offset < windowDays; offset++) {
|
for (int offset = 0; offset < windowDays; offset++) {
|
||||||
@@ -66,7 +68,8 @@ public class ApiStatusUptimeService {
|
|||||||
|
|
||||||
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
|
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
|
||||||
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
List<DjbApistatusIncident> incidents = incidentQueryRepository
|
||||||
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
|
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
|
||||||
|
catalogService.getVisibleApiIdsForQuery()).stream()
|
||||||
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
|||||||
+11
@@ -1,5 +1,8 @@
|
|||||||
package com.eactive.apim.portal.djb.community.qna.comment.controller;
|
package com.eactive.apim.portal.djb.community.qna.comment.controller;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException;
|
||||||
|
import com.eactive.apim.portal.common.security.WriteRateLimitService;
|
||||||
|
import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
|
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
|
||||||
@@ -32,6 +35,7 @@ import java.util.Map;
|
|||||||
public class InquiryCommentController {
|
public class InquiryCommentController {
|
||||||
|
|
||||||
private final InquiryCommentFacade inquiryCommentFacade;
|
private final InquiryCommentFacade inquiryCommentFacade;
|
||||||
|
private final WriteRateLimitService writeRateLimitService;
|
||||||
|
|
||||||
@GetMapping("/{inquiryId}/comments")
|
@GetMapping("/{inquiryId}/comments")
|
||||||
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
|
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
|
||||||
@@ -42,6 +46,7 @@ public class InquiryCommentController {
|
|||||||
@PostMapping("/{inquiryId}/comments")
|
@PostMapping("/{inquiryId}/comments")
|
||||||
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
||||||
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
||||||
|
writeRateLimitService.assertNotExceeded(WriteTarget.INQUIRY_COMMENT);
|
||||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
|
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
|
||||||
inquiryId, request.getContent(), request.getVisibility(), current);
|
inquiryId, request.getContent(), request.getVisibility(), current);
|
||||||
@@ -65,6 +70,12 @@ public class InquiryCommentController {
|
|||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(TooManyWriteRequestsException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleTooManyRequests(TooManyWriteRequestsException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.body(errorBody("TOO_MANY_REQUESTS", ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, String> errorBody(String code, String message) {
|
private Map<String, String> errorBody(String code, String message) {
|
||||||
Map<String, String> body = new HashMap<>();
|
Map<String, String> body = new HashMap<>();
|
||||||
body.put("code", code);
|
body.put("code", code);
|
||||||
|
|||||||
+16
@@ -1,9 +1,11 @@
|
|||||||
package com.eactive.apim.portal.djb.community.qna.comment.repository;
|
package com.eactive.apim.portal.djb.community.qna.comment.repository;
|
||||||
|
|
||||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -17,4 +19,18 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
|
|||||||
+ " group by c.inquiry.id")
|
+ " group by c.inquiry.id")
|
||||||
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
|
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
|
||||||
@Param("delYn") String delYn);
|
@Param("delYn") String delYn);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
long deleteByInquiry_Inquirer_Id(String inquirerId);
|
||||||
|
|
||||||
|
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
|
||||||
|
@Transactional
|
||||||
|
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 댓글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||||
|
* createdBy 는 결정적 암호화라 평문 사용자 id 로 등가 조회가 가능하다.
|
||||||
|
* 삭제(delYn='Y')된 댓글도 포함한다 — 작성 후 지우는 방식의 우회를 막기 위함.
|
||||||
|
*/
|
||||||
|
List<InquiryComment> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.eactive.apim.portal.djb.notitest;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
|
||||||
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림 발송 테스트 이력 1건. HttpSession(속성 {@code NOTI_TEST_HISTORY})에 리스트로 보관한다.
|
||||||
|
*
|
||||||
|
* <p>DB 테이블을 신설하지 않는 요구사항에 따라, 상태는 저장하지 않고 매 폴링마다
|
||||||
|
* {@code requestIds}로 PTL_MESSAGE_REQUEST를 다시 조회해 {@link #refreshStatus} 로 갱신한다.</p>
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class NotiTestHistoryEntry implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final String entryId;
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private final LocalDateTime requestedAt;
|
||||||
|
private final NotiTestTarget.Category category;
|
||||||
|
private final String targetLabel;
|
||||||
|
private final String messageCode;
|
||||||
|
private final NotiTestTarget.Strategy strategy;
|
||||||
|
private final String recipient;
|
||||||
|
private final List<String> messages;
|
||||||
|
private final List<String> requestIds;
|
||||||
|
private final Map<String, String> statuses = new LinkedHashMap<>();
|
||||||
|
private final String warning;
|
||||||
|
private final String errorMessage;
|
||||||
|
|
||||||
|
private NotiTestHistoryEntry(NotiTestTarget target, String recipient, NotiTestResult result) {
|
||||||
|
this.entryId = UUID.randomUUID().toString();
|
||||||
|
this.requestedAt = LocalDateTime.now();
|
||||||
|
this.category = target.getCategory();
|
||||||
|
this.targetLabel = target.getLabel();
|
||||||
|
this.messageCode = target.getMessageCode().name();
|
||||||
|
this.strategy = target.getStrategy();
|
||||||
|
this.recipient = recipient;
|
||||||
|
this.messages = result.getCreated().stream()
|
||||||
|
.map(MessageRequest::getMessage)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
this.requestIds = result.getCreated().stream().map(MessageRequest::getId).collect(Collectors.toList());
|
||||||
|
this.warning = result.getWarning();
|
||||||
|
this.errorMessage = result.getErrorMessage();
|
||||||
|
for (MessageRequest request : result.getCreated()) {
|
||||||
|
this.statuses.put(request.getId(), request.getRequestStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotiTestHistoryEntry of(NotiTestTarget target, String recipient, NotiTestResult result) {
|
||||||
|
return new NotiTestHistoryEntry(target, recipient, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 폴링 시 최신 상태로 덮어쓴다. 세션에 다시 저장하는 쪽은 호출부 책임이다. */
|
||||||
|
public void refreshStatus(Map<String, MessageRequest> byId) {
|
||||||
|
for (String id : requestIds) {
|
||||||
|
MessageRequest request = byId.get(id);
|
||||||
|
if (request != null) {
|
||||||
|
statuses.put(id, request.getRequestStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.eactive.apim.portal.djb.notitest;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림 발송 테스트 페이지 관련 {@code PTL_PROPERTY} 접근 래퍼.
|
||||||
|
*
|
||||||
|
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다({@link com.eactive.apim.portal.djb.swing.SwingNotifyProperties} 참고).
|
||||||
|
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를 생성하므로
|
||||||
|
* 별도 초기 데이터 없이도 동작하며, 운영자는 PTL_PROPERTY 값만 바꿔 재배포 없이 on/off·계정 추가가 가능하다.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotiTestProperties {
|
||||||
|
|
||||||
|
public static final String GROUP = "Portal";
|
||||||
|
|
||||||
|
public static final String KEY_ENABLED = "notification-test.enabled";
|
||||||
|
public static final String KEY_ALLOWED_EMAILS = "notification-test.allowed-emails";
|
||||||
|
|
||||||
|
private static final String LIST_DELIMITERS = "[,;\\r\\n]";
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
String value = resolve(KEY_ENABLED, "false", "알림 발송 테스트 페이지 활성화 여부(true/false)");
|
||||||
|
return "true".equalsIgnoreCase(value == null ? null : value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowed(String email) {
|
||||||
|
if (email == null || email.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String allowList = resolve(KEY_ALLOWED_EMAILS, "",
|
||||||
|
"알림 발송 테스트 페이지 접근 허용 이메일 목록 (콤마/세미콜론/줄바꿈 구분)");
|
||||||
|
if (allowList == null || allowList.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String target = email.trim();
|
||||||
|
for (String token : allowList.split(LIST_DELIMITERS)) {
|
||||||
|
String candidate = token.trim();
|
||||||
|
if (!candidate.isEmpty() && candidate.equalsIgnoreCase(target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolve(String key, String defaultValue, String description) {
|
||||||
|
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.eactive.apim.portal.djb.notitest;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link NotiTestService#send} 한 번 호출의 결과. 화면/세션에는 이 값을 그대로 담지 않고
|
||||||
|
* {@link NotiTestHistoryEntry}로 변환해서 보관한다(엔티티를 세션에 직렬화하지 않기 위함).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class NotiTestResult {
|
||||||
|
|
||||||
|
private final boolean success;
|
||||||
|
private final List<MessageRequest> created;
|
||||||
|
private final String warning;
|
||||||
|
private final String errorMessage;
|
||||||
|
|
||||||
|
private NotiTestResult(boolean success, List<MessageRequest> created, String warning, String errorMessage) {
|
||||||
|
this.success = success;
|
||||||
|
this.created = created;
|
||||||
|
this.warning = warning;
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotiTestResult of(List<MessageRequest> created, String warning) {
|
||||||
|
return new NotiTestResult(true, created == null ? new ArrayList<>() : created, warning, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotiTestResult error(String errorMessage) {
|
||||||
|
return new NotiTestResult(false, new ArrayList<>(), null, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.eactive.apim.portal.djb.notitest;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
|
||||||
|
import com.eactive.apim.portal.djb.swing.SwingMessageWriter;
|
||||||
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
|
import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageEventHandler;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageSendEvent;
|
||||||
|
import com.eactive.apim.portal.template.service.MessageSendService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림 발송 테스트 페이지의 발송 로직. {@link NotiTestTarget.Strategy} 에 따라 세 갈래로 분기한다.
|
||||||
|
*
|
||||||
|
* <p>HANDLER 전략은 {@code MessageHandlerService.publishEvent}(이벤트버스, fire-and-forget)를 쓰지 않는다 —
|
||||||
|
* 반환값을 받을 방법이 없기 때문이다. 대신 핸들러의 {@code createEvent}만 재사용해 파라미터 매핑 로직은
|
||||||
|
* 그대로 살리고, {@code MessageSendService.sendMessage}를 직접 호출해 생성된 {@link MessageRequest} 목록을
|
||||||
|
* 돌려받는다. {@code MessageSendEvent} 구독자는 {@code MessageHandlerService.handleMessageSendEvent} 한
|
||||||
|
* 곳뿐이라 이 우회는 안전하다.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotiTestService {
|
||||||
|
|
||||||
|
private final MessageHandlerService messageHandlerService;
|
||||||
|
private final MessageSendService messageSendService;
|
||||||
|
private final SwingMessageWriter swingMessageWriter;
|
||||||
|
private final MessageTemplateRepository messageTemplateRepository;
|
||||||
|
|
||||||
|
public NotiTestResult send(String messageCodeName, Map<String, String> formParams) {
|
||||||
|
Optional<NotiTestTarget> targetOpt = NotiTestTarget.byMessageCode(messageCodeName);
|
||||||
|
if (!targetOpt.isPresent()) {
|
||||||
|
return NotiTestResult.error("알 수 없는 테스트 대상입니다: " + messageCodeName);
|
||||||
|
}
|
||||||
|
NotiTestTarget target = targetOpt.get();
|
||||||
|
Map<String, String> domainParams = extractDomainParams(target, formParams);
|
||||||
|
|
||||||
|
switch (target.getStrategy()) {
|
||||||
|
case HANDLER:
|
||||||
|
return sendViaHandler(target, buildRecipient(formParams), domainParams);
|
||||||
|
case DIRECT:
|
||||||
|
return sendDirect(target, buildRecipient(formParams), domainParams);
|
||||||
|
case BROADCAST:
|
||||||
|
return sendBroadcast(target, domainParams);
|
||||||
|
default:
|
||||||
|
return NotiTestResult.error("알 수 없는 발송 전략입니다: " + target.getStrategy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NotiTestResult sendViaHandler(NotiTestTarget target, MessageRecipient recipient,
|
||||||
|
Map<String, String> domainParams) {
|
||||||
|
MessageEventHandler handler = messageHandlerService.getHandlers().get(target.getMessageCode().name());
|
||||||
|
if (handler == null) {
|
||||||
|
// 카탈로그가 HANDLER 로 분류했는데 빈이 없는 방어적 상황(운영 배포 누락 등)
|
||||||
|
return NotiTestResult.error("핸들러 빈이 등록되어 있지 않습니다: " + target.getMessageCode());
|
||||||
|
}
|
||||||
|
Map<String, Object> rawParams = new HashMap<>(domainParams);
|
||||||
|
MessageSendEvent event = handler.createEvent(this, recipient, rawParams);
|
||||||
|
List<MessageRequest> created =
|
||||||
|
messageSendService.sendMessage(event.getMessageCode(), event.getRecipient(), event.getParams());
|
||||||
|
return NotiTestResult.of(created, additionalRecipientWarning(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
private NotiTestResult sendDirect(NotiTestTarget target, MessageRecipient recipient,
|
||||||
|
Map<String, String> domainParams) {
|
||||||
|
List<MessageRequest> created =
|
||||||
|
messageSendService.sendMessage(target.getMessageCode(), recipient, new HashMap<>(domainParams));
|
||||||
|
return NotiTestResult.of(created, additionalRecipientWarning(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
private NotiTestResult sendBroadcast(NotiTestTarget target, Map<String, String> domainParams) {
|
||||||
|
Map<String, Object> params = new HashMap<>(domainParams);
|
||||||
|
List<MessageRequest> created = swingMessageWriter.write(target.getMessageCode(), params);
|
||||||
|
return NotiTestResult.of(created, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 실서비스 템플릿에 추가 수신자(PTL_MESSAGE_RECIPIENT)가 등록돼 있으면 테스트 발송에도 함께 나간다는 경고. */
|
||||||
|
private String additionalRecipientWarning(NotiTestTarget target) {
|
||||||
|
return messageTemplateRepository.findById(target.getMessageCode().name())
|
||||||
|
.map(t -> t.getAdditionalRecipients())
|
||||||
|
.filter(list -> list != null && !list.isEmpty())
|
||||||
|
.map(list -> "이 항목은 실서비스 템플릿을 사용합니다. 등록된 추가 수신자 "
|
||||||
|
+ list.size() + "명에게도 함께 발송됩니다.")
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code param_<key>} 폼 필드를 읽되, 비어있으면 카탈로그의 샘플값으로 채운다. */
|
||||||
|
private Map<String, String> extractDomainParams(NotiTestTarget target, Map<String, String> formParams) {
|
||||||
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, String> sample : target.getSampleParams().entrySet()) {
|
||||||
|
String value = formParams.get("param_" + sample.getKey());
|
||||||
|
result.put(sample.getKey(), StringUtils.hasText(value) ? value.trim() : sample.getValue());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MessageRecipient buildRecipient(Map<String, String> formParams) {
|
||||||
|
String email = trimToNull(formParams.get("recipientEmail"));
|
||||||
|
return MessageRecipient.builder()
|
||||||
|
.username(trimToNull(formParams.get("recipientUsername")))
|
||||||
|
.userId(email)
|
||||||
|
.email(email)
|
||||||
|
.phone(trimToNull(formParams.get("recipientPhone")))
|
||||||
|
.messengerId(trimToNull(formParams.get("recipientMessengerId")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String value) {
|
||||||
|
return StringUtils.hasText(value) ? value.trim() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.eactive.apim.portal.djb.notitest.catalog;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림 발송 테스트 페이지가 다루는 16종 테스트 대상 카탈로그.
|
||||||
|
*
|
||||||
|
* <p>발송 경로가 코드 위치에 따라 세 갈래로 갈린다({@link Strategy} 참고). 항목을 추가/변경할 때는
|
||||||
|
* 이 enum 하나만 고치면 된다 — 컨트롤러/서비스/화면은 이 카탈로그를 그대로 순회한다.</p>
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public enum NotiTestTarget {
|
||||||
|
|
||||||
|
// ---- 전략 HANDLER: elink-portal-common 의 MessageEventHandler 재사용 ----
|
||||||
|
EMAIL_VERIFICATION(Category.EMAIL, "이메일 인증코드", MessageCode.USER_VERIFICATION_EMAIL,
|
||||||
|
Strategy.HANDLER, params("authNumber", "123456")),
|
||||||
|
SMS_VERIFICATION(Category.SMS, "SMS 인증코드", MessageCode.USER_VERIFICATION_MOBILEPHONE,
|
||||||
|
Strategy.HANDLER, params("authNumber", "123456")),
|
||||||
|
SMS_ACCOUNT_LOCKED(Category.SMS, "계정 잠김", MessageCode.USER_ACCOUNT_LOCKED,
|
||||||
|
Strategy.HANDLER, params("reason", "로그인 5회 실패")),
|
||||||
|
SMS_PASSWORD_CHANGED(Category.SMS, "비밀번호 변경", MessageCode.USER_PASSWORD_CHANGED,
|
||||||
|
Strategy.HANDLER, params()),
|
||||||
|
SMS_INVITATION(Category.SMS, "법인 내 초대", MessageCode.USER_INVITATION,
|
||||||
|
Strategy.HANDLER, params(
|
||||||
|
"corpName", "(주)테스트법인",
|
||||||
|
"managerName", "홍길동",
|
||||||
|
"url", "https://example.com/invite",
|
||||||
|
"authNumber", "123456")),
|
||||||
|
SMS_INVITATION_CANCELED(Category.SMS, "법인 내 초대 취소", MessageCode.USER_INVITATION_CANCELED,
|
||||||
|
Strategy.HANDLER, params(
|
||||||
|
"corpName", "(주)테스트법인",
|
||||||
|
"managerName", "홍길동")),
|
||||||
|
|
||||||
|
// ---- 전략 DIRECT: eapim-admin 전용 핸들러(클래스패스 미공유) — MessageSendService 직접 호출 ----
|
||||||
|
SMS_ORG_APPROVED(Category.SMS, "서비스 가입 승인", MessageCode.MANAGER_WITH_ORG_REGISTER_APPROVED,
|
||||||
|
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인")),
|
||||||
|
SMS_ORG_REJECTED(Category.SMS, "서비스 가입 거절", MessageCode.MANAGER_WITH_ORG_REGISTER_REJECTED,
|
||||||
|
Strategy.DIRECT, params("ORG_NAME", "(주)테스트법인", "REASON", "서류 미비")),
|
||||||
|
SMS_APP_APPROVED(Category.SMS, "클라이언트 승인", MessageCode.APP_APPROVE,
|
||||||
|
Strategy.DIRECT, params("APP_NAME", "테스트앱")),
|
||||||
|
SMS_APP_REJECTED(Category.SMS, "클라이언트 거절", MessageCode.APP_REJECTED,
|
||||||
|
Strategy.DIRECT, params("APP_NAME", "테스트앱", "REASON", "정책 위반")),
|
||||||
|
SMS_ADMIN_LOGIN(Category.SMS, "관리자포탈 로그인", MessageCode.ADMIN_VERIFICATION_MOBILEPHONE,
|
||||||
|
Strategy.DIRECT, params("authNumber", "123456", "userName", "관리자")),
|
||||||
|
MSG_API_STATUS(Category.MESSENGER, "API 상태 감시", MessageCode.API_STATUS_CHANGED,
|
||||||
|
Strategy.DIRECT, params("message", "[테스트] API 상태 변화 알림")),
|
||||||
|
MSG_INFLOW_TOKEN(Category.MESSENGER, "이상 징후 감시", MessageCode.INFLOW_TOKEN_FAILED,
|
||||||
|
Strategy.DIRECT, params("message", "[테스트] 유량제어 토큰 획득 실패")),
|
||||||
|
|
||||||
|
// ---- 전략 BROADCAST: SwingMessageWriter 직접 호출(PTL_PROPERTY 역할의 내부직원 전원에게 발송) ----
|
||||||
|
MSG_QNA_CREATED(Category.MESSENGER, "QnA 질문등록", MessageCode.INQUIRY_CREATED,
|
||||||
|
Strategy.BROADCAST, params(
|
||||||
|
"inquiryId", "TEST-0001",
|
||||||
|
"inquirySubject", "[테스트] 문의 제목",
|
||||||
|
"writerName", "홍길동")),
|
||||||
|
MSG_QNA_COMMENT(Category.MESSENGER, "QnA 댓글 등록", MessageCode.INQUIRY_COMMENT_CREATED,
|
||||||
|
Strategy.BROADCAST, params(
|
||||||
|
"inquiryId", "TEST-0001",
|
||||||
|
"inquirySubject", "[테스트] 문의 제목",
|
||||||
|
"commentContent", "테스트 댓글입니다",
|
||||||
|
"writerName", "홍길동")),
|
||||||
|
MSG_PARTNERSHIP(Category.MESSENGER, "개선요청 새글 등록", MessageCode.PARTNERSHIP_CREATED,
|
||||||
|
Strategy.BROADCAST, params(
|
||||||
|
"partnershipId", "TEST-0001",
|
||||||
|
"bizSubject", "[테스트] 개선요청 제목",
|
||||||
|
"writerName", "홍길동")),
|
||||||
|
;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public enum Category {
|
||||||
|
EMAIL("이메일"), SMS("SMS"), MESSENGER("메신저");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
Category(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Strategy { HANDLER, DIRECT, BROADCAST }
|
||||||
|
|
||||||
|
private final Category category;
|
||||||
|
private final String label;
|
||||||
|
private final MessageCode messageCode;
|
||||||
|
private final Strategy strategy;
|
||||||
|
private final Map<String, String> sampleParams;
|
||||||
|
|
||||||
|
NotiTestTarget(Category category, String label, MessageCode messageCode, Strategy strategy,
|
||||||
|
Map<String, String> sampleParams) {
|
||||||
|
this.category = category;
|
||||||
|
this.label = label;
|
||||||
|
this.messageCode = messageCode;
|
||||||
|
this.strategy = strategy;
|
||||||
|
this.sampleParams = sampleParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<NotiTestTarget> byMessageCode(String messageCodeName) {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(t -> t.messageCode.name().equalsIgnoreCase(messageCodeName))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> params(String... kv) {
|
||||||
|
Map<String, String> map = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i < kv.length; i += 2) {
|
||||||
|
map.put(kv[i], kv[i + 1]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
package com.eactive.apim.portal.djb.notitest.controller;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.djb.notitest.NotiTestHistoryEntry;
|
||||||
|
import com.eactive.apim.portal.djb.notitest.NotiTestProperties;
|
||||||
|
import com.eactive.apim.portal.djb.notitest.NotiTestResult;
|
||||||
|
import com.eactive.apim.portal.djb.notitest.NotiTestService;
|
||||||
|
import com.eactive.apim.portal.djb.notitest.catalog.NotiTestTarget;
|
||||||
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
|
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림 발송 테스트 페이지.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY({@link NotiTestProperties})로 기능 on/off 와 접근 허용 이메일 목록을 관리한다.
|
||||||
|
* 운영에서도 상시 켜둘 수 있도록 GNB 에는 노출하지 않고 직접 URL 로만 접근한다(page.yml/portal.pages
|
||||||
|
* 미등록). 발송 이력은 별도 테이블 없이 HttpSession 에 보관하고, 상태는 기존 PTL_MESSAGE_REQUEST 를
|
||||||
|
* 3초 폴링으로 재조회해 갱신한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/djb/notitest")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotiTestController {
|
||||||
|
|
||||||
|
private static final String SESSION_HISTORY_KEY = "NOTI_TEST_HISTORY";
|
||||||
|
private static final int MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
private final NotiTestProperties notiTestProperties;
|
||||||
|
private final NotiTestService notiTestService;
|
||||||
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
|
|
||||||
|
/** 폼 + 이력 렌더 */
|
||||||
|
@GetMapping
|
||||||
|
public ModelAndView index(HttpSession session, HttpServletResponse response) {
|
||||||
|
if (!notiTestProperties.isEnabled()) {
|
||||||
|
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isAllowedForCurrentUser()) {
|
||||||
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalAuthenticatedUser me = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
|
||||||
|
ModelAndView mav = new ModelAndView("djb/notitest/index");
|
||||||
|
mav.addObject("targetsByCategory", groupByCategory());
|
||||||
|
mav.addObject("history", getHistory(session));
|
||||||
|
mav.addObject("defaultUsername", me.getUserName());
|
||||||
|
mav.addObject("defaultEmail", me.getEmailAddr());
|
||||||
|
mav.addObject("defaultPhone", me.getMobileNumber());
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 발송(AJAX) */
|
||||||
|
@PostMapping("/send")
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<?> send(@RequestParam Map<String, String> allParams, HttpSession session) {
|
||||||
|
ResponseEntity<?> denied = guardResponse();
|
||||||
|
if (denied != null) {
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
String messageCode = allParams.get("messageCode");
|
||||||
|
if (!StringUtils.hasText(messageCode)) {
|
||||||
|
return ResponseEntity.badRequest().body(Collections.singletonMap("error", "messageCode is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
NotiTestTarget target = NotiTestTarget.byMessageCode(messageCode).orElse(null);
|
||||||
|
if (target == null) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(Collections.singletonMap("error", "알 수 없는 테스트 대상입니다: " + messageCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
NotiTestResult result = notiTestService.send(messageCode, allParams);
|
||||||
|
NotiTestHistoryEntry entry = NotiTestHistoryEntry.of(target, resolveRecipient(target, allParams), result);
|
||||||
|
pushHistory(session, entry);
|
||||||
|
return ResponseEntity.ok(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 상태 폴링(AJAX, 3초 간격) — 별도 테이블 없이 PTL_MESSAGE_REQUEST 만 재조회한다. */
|
||||||
|
@GetMapping("/status")
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<?> status(HttpSession session) {
|
||||||
|
ResponseEntity<?> denied = guardResponse();
|
||||||
|
if (denied != null) {
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NotiTestHistoryEntry> history = getHistory(session);
|
||||||
|
Set<String> ids = history.stream()
|
||||||
|
.flatMap(h -> h.getRequestIds().stream())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
Map<String, MessageRequest> byId = messageRequestRepository.findAllById(ids).stream()
|
||||||
|
.collect(Collectors.toMap(MessageRequest::getId, Function.identity()));
|
||||||
|
history.forEach(h -> h.refreshStatus(byId));
|
||||||
|
session.setAttribute(SESSION_HISTORY_KEY, history);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<?> guardResponse() {
|
||||||
|
if (!notiTestProperties.isEnabled()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
if (!isAllowedForCurrentUser()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedForCurrentUser() {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
String email = user == null ? null : user.getEmailAddr();
|
||||||
|
boolean allowed = notiTestProperties.isAllowed(email);
|
||||||
|
if (!allowed) {
|
||||||
|
log.warn("알림 테스트 페이지 접근 거부 - email: {}", email);
|
||||||
|
}
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<NotiTestTarget.Category, List<NotiTestTarget>> groupByCategory() {
|
||||||
|
return Arrays.stream(NotiTestTarget.values())
|
||||||
|
.collect(Collectors.groupingBy(NotiTestTarget::getCategory, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRecipient(NotiTestTarget target, Map<String, String> formParams) {
|
||||||
|
if (target.getStrategy() == NotiTestTarget.Strategy.BROADCAST) {
|
||||||
|
return "실제 담당자 전원";
|
||||||
|
}
|
||||||
|
String phone = formParams.get("recipientPhone");
|
||||||
|
String email = formParams.get("recipientEmail");
|
||||||
|
String messengerId = formParams.get("recipientMessengerId");
|
||||||
|
|
||||||
|
if (target.getCategory() == NotiTestTarget.Category.EMAIL && StringUtils.hasText(email)) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(phone)) {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(email)) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(messengerId)) {
|
||||||
|
return messengerId;
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void pushHistory(HttpSession session, NotiTestHistoryEntry entry) {
|
||||||
|
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
|
||||||
|
history = (history == null) ? new ArrayList<>() : new ArrayList<>(history);
|
||||||
|
history.add(0, entry);
|
||||||
|
if (history.size() > MAX_HISTORY) {
|
||||||
|
history = new ArrayList<>(history.subList(0, MAX_HISTORY));
|
||||||
|
}
|
||||||
|
session.setAttribute(SESSION_HISTORY_KEY, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<NotiTestHistoryEntry> getHistory(HttpSession session) {
|
||||||
|
List<NotiTestHistoryEntry> history = (List<NotiTestHistoryEntry>) session.getAttribute(SESSION_HISTORY_KEY);
|
||||||
|
return (history == null) ? new ArrayList<>() : history;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -42,38 +43,43 @@ public class SwingMessageWriter {
|
|||||||
private final SwingNotifyProperties properties;
|
private final SwingNotifyProperties properties;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void write(MessageCode code, Map<String, Object> params) {
|
public List<MessageRequest> write(MessageCode code, Map<String, Object> params) {
|
||||||
String role = properties.getTargetRole();
|
String role = properties.getTargetRole();
|
||||||
List<UserInfo> staffs = swingStaffRepository.findByRole(role);
|
List<UserInfo> staffs = swingStaffRepository.findByRole(role);
|
||||||
if (staffs == null || staffs.isEmpty()) {
|
if (staffs == null || staffs.isEmpty()) {
|
||||||
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
|
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
|
||||||
return;
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
|
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
|
||||||
if (template == null) {
|
if (template == null) {
|
||||||
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
|
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
|
||||||
return;
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
|
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
|
||||||
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
|
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
|
||||||
code.name(), template.getEnableMessenger());
|
code.name(), template.getEnableMessenger());
|
||||||
return;
|
return new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
|
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
|
||||||
EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
|
EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
|
||||||
|
|
||||||
|
List<MessageRequest> created = new ArrayList<>();
|
||||||
for (UserInfo staff : staffs) {
|
for (UserInfo staff : staffs) {
|
||||||
try {
|
try {
|
||||||
writeOne(code, template, params, staff, umsIds, policy);
|
MessageRequest saved = writeOne(code, template, params, staff, umsIds, policy);
|
||||||
|
if (saved != null) {
|
||||||
|
created.add(saved);
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
|
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
|
private MessageRequest writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
|
||||||
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
|
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
|
||||||
EmployeeIdPolicy policy) {
|
EmployeeIdPolicy policy) {
|
||||||
|
|
||||||
@@ -83,10 +89,10 @@ public class SwingMessageWriter {
|
|||||||
if (!properties.isEmployeeId(messengerId)) {
|
if (!properties.isEmployeeId(messengerId)) {
|
||||||
switch (policy) {
|
switch (policy) {
|
||||||
case REJECT:
|
case REJECT:
|
||||||
return;
|
return null;
|
||||||
case REJECT_LOG:
|
case REJECT_LOG:
|
||||||
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
|
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
|
||||||
return;
|
return null;
|
||||||
case SKIP:
|
case SKIP:
|
||||||
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
|
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
|
||||||
STATUS_SKIPPED, messengerId, code.name());
|
STATUS_SKIPPED, messengerId, code.name());
|
||||||
@@ -124,8 +130,9 @@ public class SwingMessageWriter {
|
|||||||
request.setRequestStatus(requestStatus);
|
request.setRequestStatus(requestStatus);
|
||||||
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
|
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
|
||||||
|
|
||||||
messageRequestRepository.save(request);
|
MessageRequest saved = messageRequestRepository.save(request);
|
||||||
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
|
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
|
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user