Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 094336ebf1 | |||
| a310b671e2 | |||
| d9373b00f0 | |||
| 09bc3a65f5 | |||
| 1d81681e70 | |||
| d4bafa706c | |||
| 60f9f89d90 | |||
| 8ecfaf4945 | |||
| fa736a36ab | |||
| b7869ad6e6 | |||
| 982187298c | |||
| 8118dba74d | |||
| 97489d0361 | |||
| 924c00968c | |||
| 53c3b0ee7e | |||
| b2787882bf | |||
| aafa7ac351 | |||
| b7477e10a1 | |||
| 11f1dae1f3 | |||
| d275aa12c4 |
@@ -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
|
||||
cd build/libs
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,9 @@ pipeline {
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
md5sum "$f" > "$f.md5"
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -87,6 +87,10 @@ dependencies {
|
||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||
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-cache')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
@@ -190,13 +194,13 @@ ext {
|
||||
// 2.18.x 마지막 패치를 쓴다.
|
||||
set('jackson-bom.version', '2.18.10')
|
||||
|
||||
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
|
||||
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
|
||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
|
||||
// org.thymeleaf.spring5 + javax.servlet 그대로다.
|
||||
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
|
||||
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
|
||||
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
|
||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
|
||||
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
|
||||
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
|
||||
// 주의: 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')
|
||||
|
||||
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||
@@ -315,6 +319,18 @@ war {
|
||||
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 {
|
||||
doLast {
|
||||
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,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",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"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": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
@@ -322,6 +767,22 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -349,6 +810,48 @@
|
||||
"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": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
@@ -389,6 +892,16 @@
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -403,6 +916,35 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
|
||||
+7
-2
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "eapim-portal",
|
||||
"version": "1.0.0",
|
||||
"description": "SASS build system for EAPIM Portal",
|
||||
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
||||
"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: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",
|
||||
"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": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"sass": "^1.69.5"
|
||||
},
|
||||
"author": "",
|
||||
|
||||
@@ -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.service.ApiListProperties;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
@@ -17,6 +18,7 @@ import java.util.Optional;
|
||||
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;
|
||||
@@ -37,6 +39,7 @@ public class ApiController {
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final ApiSearchFacade apiSearchFacade;
|
||||
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_NAME = "인증";
|
||||
|
||||
@@ -88,7 +91,8 @@ public class ApiController {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
||||
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
|
||||
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
|
||||
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
|
||||
|
||||
model.addAttribute("search", search);
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
@@ -109,8 +113,8 @@ public class ApiController {
|
||||
*/
|
||||
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||
int total = apis == null ? 0 : apis.size();
|
||||
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
|
||||
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.app.service;
|
||||
|
||||
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.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||
*
|
||||
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
||||
* 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
|
||||
@Service
|
||||
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
|
||||
private static final String PROP_GROUP = "Portal";
|
||||
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||
private static final String 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 final RestTemplate restTemplate;
|
||||
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
|
||||
/**
|
||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||
*
|
||||
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
|
||||
*
|
||||
* @param clientId 차단할 클라이언트 ID
|
||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||
*/
|
||||
public void blockClient(String clientId) {
|
||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||
List<String> baseUrls = resolveBaseUrls();
|
||||
|
||||
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 = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||
ResponseEntity<Map> response;
|
||||
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();
|
||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||
if (!success) {
|
||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
||||
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
|
||||
Map<?, ?> body = response.getBody();
|
||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||
if (!success) {
|
||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||
throw new IllegalStateException(
|
||||
"admin clientBlock 처리 실패 - 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+8
-3
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||
|
||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||
LocalDate today = LocalDate.now();
|
||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
||||
searchDto.setEndDate(LocalDate.now());
|
||||
searchDto.setStartDate(today.minusDays(7));
|
||||
searchDto.setEndDate(today);
|
||||
searchDto.setClientId(null);
|
||||
|
||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("details", result.getDetails());
|
||||
model.addAttribute("periods", result.getPeriods());
|
||||
model.addAttribute("searchDto", searchDto);
|
||||
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||
model.addAttribute("statsMaxDate", today.toString());
|
||||
|
||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly()
|
||||
? "조회할 월이 올바르지 않습니다."
|
||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
||||
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||
}
|
||||
|
||||
private PortalOrg getPortalOrg() {
|
||||
|
||||
+9
-4
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
||||
public class ApiStatisticsSearchDto {
|
||||
|
||||
/**
|
||||
* 최대 조회 가능 일수 (일별 모드).
|
||||
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||
*/
|
||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
||||
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||
*/
|
||||
public boolean isValidDateRange() {
|
||||
return isValidDateRange(LocalDate.now());
|
||||
}
|
||||
|
||||
boolean isValidDateRange(LocalDate today) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return false;
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
return false;
|
||||
}
|
||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
||||
LocalDate earliestDate = today.minusYears(1);
|
||||
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
|
||||
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
@@ -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.repository.PortalUserRepository;
|
||||
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -64,7 +57,6 @@ public class UserRegisterController {
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
private final PortalProperties portalProperties;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
|
||||
@@ -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.UserRegistrationValidationService;
|
||||
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.service.FileService;
|
||||
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.PortalUserEnums;
|
||||
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 org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
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.Optional;
|
||||
|
||||
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final ApprovalService approvalService;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
approvalService.createUserApproval(newUser);
|
||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendActivationEmail(newUser);
|
||||
|
||||
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(
|
||||
PortalUser existingUser,
|
||||
|
||||
@@ -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.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.UserAgreementDTO;
|
||||
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.validator.AgreementValidator;
|
||||
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.UserInvitationEnums;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
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 org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
private final PasswordValidator passwordValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AuthNumberGenerator authNumberGenerator;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
// sendEmailActivation(newUser);
|
||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||
}
|
||||
|
||||
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
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
|
||||
@Transactional
|
||||
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
||||
|
||||
@@ -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.event.UserPasswordResetEvent;
|
||||
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.repository.MessageRequestRepository;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
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.List;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final PasswordService passwordService;
|
||||
|
||||
|
||||
+15
-6
@@ -130,13 +130,22 @@ public class GlobalControllerAdvice {
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 사용 여부(yml portal.security.password-encrypt.enabled).
|
||||
* head 의 window.__PASSWORD_CRYPTO__ 로 내려가 password-crypto.js 가 읽는다.
|
||||
* 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 화면은 평문으로 폴백한다.
|
||||
* 비밀번호 전송암호화 설정(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("passwordCryptoEnabled")
|
||||
public boolean passwordCryptoEnabled() {
|
||||
return passwordCryptoProperties.isEnabled();
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+43
-10
@@ -18,9 +18,17 @@ import java.util.Set;
|
||||
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
|
||||
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
|
||||
*
|
||||
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 1})보다 <b>앞</b>에서 이 래퍼가 씌워지므로,
|
||||
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 비밀번호의
|
||||
* 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</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 {
|
||||
@@ -37,6 +45,13 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
private Map<String, String[]> parameterMapCache;
|
||||
|
||||
/**
|
||||
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
|
||||
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
|
||||
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
|
||||
*/
|
||||
private boolean plaintextWarned;
|
||||
|
||||
public DecryptingRequestWrapper(HttpServletRequest request,
|
||||
PasswordEnvelopeCodec codec,
|
||||
PasswordKeyStore keyStore,
|
||||
@@ -97,13 +112,7 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
||||
return value;
|
||||
}
|
||||
if (!codec.isEnvelope(value)) {
|
||||
// strict 모드에서는 평문 비밀번호를 받아들이지 않는다. 예외를 던지면 로그인 경로가 500 이 되므로
|
||||
// 빈 값으로 바꿔 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.
|
||||
if (properties.isStrict() && PasswordParamNames.isPasswordLike(name)) {
|
||||
log.warn("암호화되지 않은 비밀번호 파라미터 거부(strict) - uri={}, param={}", getRequestURI(), name);
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
return handlePlaintext(name, value);
|
||||
}
|
||||
|
||||
String cached = decrypted.get(value);
|
||||
@@ -137,6 +146,30 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 봉투가 씌워지지 않은 파라미터 처리. 비밀번호 계열이 아니면 그대로 통과시킨다.
|
||||
*
|
||||
* <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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
|
||||
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
|
||||
|
||||
+32
-8
@@ -7,12 +7,11 @@ import org.springframework.stereotype.Component;
|
||||
/**
|
||||
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
|
||||
*
|
||||
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.
|
||||
* 로컬 개발환경은 HTTP(비 secure context)라 브라우저 {@code crypto.subtle} 을 쓸 수 없으므로
|
||||
* {@link #enabled} 를 꺼둔다. 켜져 있어도 브라우저가 지원하지 못하면 클라이언트가 평문으로 폴백한다.</p>
|
||||
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.</p>
|
||||
*
|
||||
* <p><b>이 기능은 XSS 방어가 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있다.
|
||||
* "전송 페이로드 평문 노출" 점검 지적에 대한 대응 범위로만 이해할 것.</p>
|
||||
* <p><b>이 기능은 XSS 방어도, MITM 방어도 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있고,
|
||||
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. "전송 페이로드 평문 노출" 점검 지적에
|
||||
* 대한 대응 범위로만 이해할 것.</p>
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@@ -32,6 +31,24 @@ public class PasswordCryptoProperties {
|
||||
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;
|
||||
|
||||
@@ -41,11 +58,18 @@ public class PasswordCryptoProperties {
|
||||
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
|
||||
private int keyTtlSeconds = 300;
|
||||
|
||||
/** 평문 비밀번호 파라미터 처리 정책. */
|
||||
private PlaintextPolicy plaintextPolicy = PlaintextPolicy.NONE;
|
||||
|
||||
/**
|
||||
* true 면 서버가 평문 비밀번호 파라미터를 거부한다(빈 값으로 치환 → 인증 실패).
|
||||
* JS 비활성 사용자나 구형 브라우저가 로그인하지 못하게 되므로 안정화 후에만 켠다.
|
||||
* {@code crypto.subtle} 을 쓸 수 없는 환경(원격 오리진 HTTP = 비 secure context)에서
|
||||
* 순수 JS 구현(forge)으로 봉투를 만들지 여부.
|
||||
*
|
||||
* <p>forge 번들은 {@code crypto.subtle} 이 없을 때만 동적으로 내려가므로 HTTPS 운영 환경에서는
|
||||
* 전송 바이트가 0 이다. 즉 이 값은 사실상 킬스위치다. 끄면 HTTP 개발환경은 평문으로 폴백한다
|
||||
* ({@link #plaintextPolicy} 가 {@code ENFORCE} 면 로그인 불가).</p>
|
||||
*/
|
||||
private boolean strict = false;
|
||||
private boolean softwareFallback = true;
|
||||
|
||||
/** RSA 키 길이. */
|
||||
private int rsaKeySize = 2048;
|
||||
|
||||
+26
-6
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -13,16 +14,29 @@ import java.io.IOException;
|
||||
/**
|
||||
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
|
||||
*
|
||||
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE} 로 등록해
|
||||
* Lucy XSS 필터({@code MIN_VALUE + 1})보다 먼저 실행된다. 자세한 이유는
|
||||
* {@link DecryptingRequestWrapper} 주석 참고.</p>
|
||||
* <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;
|
||||
@@ -45,17 +59,23 @@ public class PasswordDecryptFilter implements Filter {
|
||||
}
|
||||
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
if (!"POST".equalsIgnoreCase(httpRequest.getMethod())) {
|
||||
if (!"POST".equalsIgnoreCase(httpRequest.getMethod()) || isInternalApiRequest(httpRequest)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
DecryptingRequestWrapper wrapper =
|
||||
new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||
|
||||
/**
|
||||
* 비밀번호 계열 파라미터명 판별. {@code strict} 모드에서 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
|
||||
* 비밀번호 계열 파라미터명 판별. {@code plaintext-policy} 가 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
|
||||
*
|
||||
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
|
||||
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
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 static String generateNewPassword() {
|
||||
@@ -53,40 +40,4 @@ public class EncryptionUtil {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
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 org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -66,25 +67,40 @@ public class PortalConfigSecurity {
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 복호화 필터. Lucy XSS 필터(order = MIN_VALUE + 1)보다 <b>먼저</b> 실행되어야 한다.
|
||||
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 특수문자가 든 비밀번호의 해시 비교 결과가
|
||||
* 지금과 동일하게 유지된다. 순서를 뒤집으면 기존 계정 로그인이 깨진다.
|
||||
* 비밀번호 전송암호화 복호화 필터.
|
||||
*
|
||||
* <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);
|
||||
registrationBean.setOrder(Integer.MIN_VALUE + 2);
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
// order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야
|
||||
// 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐).
|
||||
@Bean
|
||||
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
||||
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
||||
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
||||
registrationBean.setFilter(xssEscapeServletFilter);
|
||||
registrationBean.setOrder(Integer.MIN_VALUE + 1);
|
||||
registrationBean.setOrder(Integer.MIN_VALUE + 3);
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
+24
-11
@@ -175,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||
* {@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) {
|
||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
||||
CacheControl cacheControl = isResourceCachingEnabled()
|
||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
||||
: CacheControl.noStore();
|
||||
boolean versioningOn = isResourceVersioningEnabled();
|
||||
CacheControl cacheControl;
|
||||
if (!isResourceCachingEnabled()) {
|
||||
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
|
||||
cacheControl = CacheControl.noStore();
|
||||
} else if (versioningOn) {
|
||||
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
|
||||
} else {
|
||||
cacheControl = CacheControl.noCache();
|
||||
}
|
||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||
.addResourceLocations(locations)
|
||||
.setCacheControl(cacheControl)
|
||||
.resourceChain(isResourceCachingEnabled());
|
||||
if (isResourceVersioningEnabled()) {
|
||||
if (versioningOn) {
|
||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||
}
|
||||
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() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceVersioningEnabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
|
||||
public RestTemplate restTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
|
||||
// 연결 타임아웃: 10초
|
||||
factory.setConnectTimeout(10000);
|
||||
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
|
||||
factory.setConnectTimeout(5000);
|
||||
|
||||
// 읽기 타임아웃: 30초
|
||||
factory.setReadTimeout(30000);
|
||||
// 읽기 타임아웃: 5초
|
||||
factory.setReadTimeout(5000);
|
||||
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("//notitest")
|
||||
@RequestMapping("/djb/notitest")
|
||||
@RequiredArgsConstructor
|
||||
public class NotiTestController {
|
||||
|
||||
|
||||
@@ -41,9 +41,11 @@ gateway:
|
||||
portal:
|
||||
# auth-virtual-code: 654321
|
||||
security:
|
||||
# 개발 서버는 HTTPS 가 아니라 브라우저 Web Crypto 를 쓸 수 없다.
|
||||
# 개발 서버는 HTTP(비 secure context)라 crypto.subtle 이 없다. software-fallback(forge)으로 암호화한다.
|
||||
password-encrypt:
|
||||
enabled: false
|
||||
enabled: true
|
||||
# 개발 서버는 원격 오리진 HTTP → crypto.subtle 이 없다. 기본값인 software-fallback(forge)으로 암호화된다.
|
||||
plaintext-policy: permissive
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
hot-reload-pages: true
|
||||
|
||||
@@ -9,9 +9,11 @@ spring:
|
||||
cachecontrol:
|
||||
max-age: 86400
|
||||
public: true
|
||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
||||
# 정적자원 콘텐츠 해시 버전닝: OHS 가 정적 리소스(css/js/img 등)를 직접 서빙하는 구조에서는
|
||||
# 해시 URL(main-<hash>.css)이 OHS 디스크의 무해시 원본과 어긋나 404 위험이 있어 OFF 로 전환했다
|
||||
# (아래 app.resource-versioning.enabled: false, PortalConfigWebDispatcherServlet 참고).
|
||||
# 캐시 무효화는 대신 no-cache(매 요청 If-Modified-Since 조건부 재검증)로 보장한다.
|
||||
# 배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리
|
||||
jpa:
|
||||
properties:
|
||||
hibernate:
|
||||
@@ -28,7 +30,7 @@ spring:
|
||||
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
enabled: false
|
||||
resource-caching:
|
||||
enabled: true
|
||||
|
||||
@@ -41,7 +43,10 @@ server:
|
||||
portal:
|
||||
security:
|
||||
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
|
||||
# strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가).
|
||||
password-encrypt:
|
||||
enabled: true
|
||||
strict: false
|
||||
# 운영은 평문 비밀번호를 받지 않는다. 봉투가 아니면 빈 값으로 치환돼 인증 실패로 떨어진다.
|
||||
# 대가: JS 를 끈 브라우저는 로그인할 수 없고, 공개키 발급(/api/security/password-key.json)이
|
||||
# 죽으면 로그인 전체가 막힌다(permissive 면 평문으로 degrade 되어 로그인은 됐을 상황).
|
||||
# 되돌리려면 이 값을 permissive 로 바꾸고 재기동해야 한다 — yml 이라 무중단 토글은 안 된다.
|
||||
plaintext-policy: enforce
|
||||
|
||||
@@ -23,9 +23,10 @@ portal:
|
||||
# 검증 단계에선 사용하지 않음
|
||||
# auth-virtual-code: 654321
|
||||
security:
|
||||
# HTTPS 구간이라 전송암호화를 켠다. strict 는 운영 승격 전 검증 후 판단.
|
||||
# HTTPS 구간이라 전송암호화를 켠다. plaintext-policy 승격(ENFORCE)은 운영 승격 전 검증 후 판단.
|
||||
password-encrypt:
|
||||
enabled: true
|
||||
plaintext-policy: permissive
|
||||
dev:
|
||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||
hot-reload-pages: true
|
||||
|
||||
@@ -155,15 +155,50 @@ portal:
|
||||
|
||||
security:
|
||||
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
|
||||
# 로컬/개발 서버는 HTTP(비 secure context)라 브라우저 crypto.subtle 을 못 쓰므로 기본은 꺼둔다.
|
||||
# 켜져 있어도 브라우저가 지원하지 못하면 화면이 평문으로 폴백한다.
|
||||
#
|
||||
# 화면(password-crypto.js)이 서버 공개키로 AES 키를 감싸고 비밀번호를 그 AES 키로 암호화해
|
||||
# ENC1.<keyId>.<랩된AES키>.<iv>.<암호문+태그> 봉투로 보낸다. 서버는 PasswordDecryptFilter 가
|
||||
# 파라미터를 평문으로 되돌리므로 컨트롤러는 이 기능을 몰라도 된다.
|
||||
#
|
||||
# 주의: XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 털리고,
|
||||
# HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다.
|
||||
#
|
||||
# 아래는 모든 키의 기본값이다. 프로파일 yml 에는 이 값과 다른 것만 적는다.
|
||||
password-encrypt:
|
||||
# 마스터 스위치. false 면 필터·키 발급 엔드포인트가 모두 무동작이고 화면은 평문 전송한다.
|
||||
# 기본은 꺼둔다 — 켜는 것은 프로파일 yml 의 판단.
|
||||
enabled: false
|
||||
# REQUEST(폼당 1회용) | SESSION(세션당) | SERVER(서버 고정 + TTL 로테이션)
|
||||
|
||||
# RSA 개인키 보관 범위.
|
||||
# REQUEST - 폼 진입마다 1회용 키 발급, 요청 1회 사용 후 폐기. 재전송 차단이 가장 강하다.
|
||||
# SESSION - 세션 단위 보관. 세션 복제 환경에 유리.
|
||||
# SERVER - 서버 고정 키쌍 + TTL 로테이션. 무상태라 단순하지만 재전송 방지 수단이 없다.
|
||||
key-scope: REQUEST
|
||||
|
||||
# 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기.
|
||||
key-ttl-seconds: 300
|
||||
# true 면 평문 비밀번호 파라미터를 거부한다. JS 비활성 사용자가 로그인하지 못하므로 안정화 후에만 켠다.
|
||||
strict: false
|
||||
|
||||
# 봉투가 씌워지지 않은 '평문 비밀번호 파라미터' 를 서버가 어떻게 다룰지.
|
||||
# 전송 구간(HTTP/HTTPS)을 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
|
||||
# none - 무동작. 평문을 그대로 받는다.
|
||||
# permissive - 평문을 받되 서버 로그에 경고를 남기고(요청당 1줄), 화면에도 경고 팝업을 띄운다.
|
||||
# HTTP 접속이면 비밀번호 입력 화면에서 세션당 1회 팝업이 뜬다.
|
||||
# enforce - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).
|
||||
# JS 를 끈 브라우저는 로그인하지 못하므로, permissive 로 운영하며 위 경고 로그가
|
||||
# 안 나오는 것을 확인한 뒤에 승격한다.
|
||||
# 기본은 none — 어느 환경에 올려도 기존 동작이 바뀌지 않게 한다.
|
||||
# (enabled: false 면 필터가 통째로 무동작이라 이 값은 어차피 영향이 없다.)
|
||||
plaintext-policy: none
|
||||
|
||||
# crypto.subtle 을 쓸 수 없는 환경에서 순수 JS 구현(forge)으로 봉투를 만들지 여부.
|
||||
# 브라우저는 secure context(HTTPS·localhost)가 아니면 crypto.subtle 을 아예 노출하지 않는다.
|
||||
# 즉 원격 오리진 HTTP(사내 IP 접속 등)에서는 이 값을 켜야 암호화가 걸린다.
|
||||
# forge 번들(js/lib/forge-crypto.min.js, gzip 약 31KB)은 crypto.subtle 이 없을 때만 동적으로
|
||||
# 내려가므로 HTTPS 구간에서는 전송 바이트가 0 이다. 사실상 킬스위치.
|
||||
# 기본은 true — HTTPS 구간에서는 어차피 내려가지 않고, HTTP 구간에서는 켜져 있어야 의미가 있다.
|
||||
software-fallback: true
|
||||
|
||||
# RSA 키 길이.
|
||||
rsa-key-size: 2048
|
||||
|
||||
pages:
|
||||
|
||||
@@ -1247,7 +1247,7 @@ hr {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
background: rgb(0, 65.7, 162);
|
||||
}
|
||||
.mobile-drawer .drawer-welcome.authenticated {
|
||||
flex-direction: row;
|
||||
@@ -2574,7 +2574,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-danger {
|
||||
@@ -2582,7 +2582,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-ghost {
|
||||
@@ -2848,7 +2848,7 @@ hr {
|
||||
.action-btn-delete:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
}
|
||||
.action-btn-delete:active {
|
||||
transform: translateY(0);
|
||||
@@ -2966,7 +2966,7 @@ hr {
|
||||
background: #a4d6ea;
|
||||
}
|
||||
.btn-input-action.btn-change:hover {
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
}
|
||||
@@ -3041,7 +3041,7 @@ hr {
|
||||
border: none;
|
||||
}
|
||||
.btn-action-primary:hover {
|
||||
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
|
||||
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
|
||||
transform: translateY(-2px);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -3091,7 +3091,7 @@ hr {
|
||||
}
|
||||
.status-badge.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
}
|
||||
.status-badge.status-failed {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
@@ -3131,7 +3131,7 @@ hr {
|
||||
}
|
||||
.status-badge-header.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
@@ -4319,7 +4319,7 @@ select.form-control {
|
||||
.file-upload-wrapper .file-remove-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
}
|
||||
.file-upload-wrapper .file-remove-btn:active {
|
||||
transform: translateY(0);
|
||||
@@ -4640,7 +4640,7 @@ select.form-control {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link:hover {
|
||||
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
|
||||
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link img {
|
||||
width: 22px;
|
||||
@@ -4795,7 +4795,7 @@ select.form-control {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.notice-content-box a:hover {
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
color: rgb(0, 65.7, 162);
|
||||
}
|
||||
|
||||
.form-row--content .form-label-wrapper {
|
||||
@@ -5067,6 +5067,8 @@ select.form-control {
|
||||
color: #212529;
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.modal-body p {
|
||||
margin: 0;
|
||||
@@ -5733,7 +5735,7 @@ select.form-control {
|
||||
font-size: 16px;
|
||||
}
|
||||
.drawer-logout-btn:hover {
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||
}
|
||||
@@ -6447,7 +6449,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--default:hover {
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
}
|
||||
.list-table-btn--primary {
|
||||
background-color: #ecf0fa;
|
||||
@@ -6455,7 +6457,7 @@ select.form-control {
|
||||
color: #2a69de;
|
||||
}
|
||||
.list-table-btn--primary:hover {
|
||||
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
|
||||
background-color: rgb(216.7625, 224.8125, 244.9375);
|
||||
}
|
||||
.list-table-btn--secondary {
|
||||
background-color: #f5f5f4;
|
||||
@@ -6463,7 +6465,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--secondary:hover {
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
}
|
||||
.list-table-btn--danger {
|
||||
background-color: #fbe7e9;
|
||||
@@ -6471,7 +6473,7 @@ select.form-control {
|
||||
color: #bb1026;
|
||||
}
|
||||
.list-table-btn--danger:hover {
|
||||
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
|
||||
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
@@ -7121,7 +7123,7 @@ select.form-control {
|
||||
.alert.alert-error {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
color: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
color: rgb(255, 70.8, 70.8);
|
||||
align-items: center;
|
||||
}
|
||||
.alert.alert-error svg {
|
||||
@@ -7135,7 +7137,7 @@ select.form-control {
|
||||
.alert.alert-success {
|
||||
background: rgba(107, 207, 127, 0.1);
|
||||
border: 1px solid rgba(107, 207, 127, 0.3);
|
||||
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
|
||||
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
|
||||
}
|
||||
.alert.alert-info {
|
||||
background: rgba(0, 73, 180, 0.1);
|
||||
@@ -11549,10 +11551,10 @@ body.index-page-body {
|
||||
line-height: 20px;
|
||||
}
|
||||
.login-button:hover {
|
||||
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
|
||||
background: rgb(25.65, 70.3, 173.85);
|
||||
}
|
||||
.login-button:active {
|
||||
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
|
||||
background: rgb(24.3, 66.6, 164.7);
|
||||
}
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -11595,10 +11597,10 @@ body.index-page-body {
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
.login-links-container .link-btn:hover {
|
||||
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
|
||||
background: rgb(220.61, 227.85, 245.95);
|
||||
}
|
||||
.login-links-container .link-btn:active {
|
||||
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
|
||||
background: rgb(205.22, 215.7, 241.9);
|
||||
}
|
||||
|
||||
.login-alert {
|
||||
@@ -12107,12 +12109,12 @@ body.index-page-body {
|
||||
}
|
||||
.auth-request-button:hover,
|
||||
.auth-verify-button:hover {
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
transform: none !important;
|
||||
}
|
||||
.auth-request-button:active,
|
||||
.auth-verify-button:active {
|
||||
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
|
||||
background: rgb(21.411588785, 146.3125233645, 233.148411215);
|
||||
}
|
||||
.auth-request-button:disabled,
|
||||
.auth-verify-button:disabled {
|
||||
@@ -12161,10 +12163,10 @@ body.index-page-body {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:hover {
|
||||
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
|
||||
background: rgb(225.45, 229.39, 235.3);
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:active {
|
||||
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
|
||||
background: rgb(210.9, 216.78, 225.6);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button {
|
||||
color: #FFFFFF;
|
||||
@@ -12174,7 +12176,7 @@ body.index-page-body {
|
||||
background: rgb(6, 54, 125);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:active {
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
background: rgb(0, 65.7, 162);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -12410,7 +12412,7 @@ body.index-page-body {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.result-info-box .info-text .info-link:hover {
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
color: rgb(0, 65.7, 162);
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.result-info-box .info-text {
|
||||
@@ -17610,7 +17612,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.btn-copy-action {
|
||||
@@ -17637,7 +17639,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
}
|
||||
.btn-view-secret svg {
|
||||
width: 20px;
|
||||
@@ -17822,7 +17824,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
}
|
||||
.btn-view-secret {
|
||||
width: 100% !important;
|
||||
@@ -17838,7 +17840,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
height: 16px;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
}
|
||||
#revealedSecretBox {
|
||||
width: 100%;
|
||||
@@ -18111,7 +18113,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-wrap .dt-btn-copy:hover {
|
||||
background: rgb(74.3529411765%, 90.2296918768%, 100%);
|
||||
background: rgb(189.6, 230.0857142857, 255);
|
||||
}
|
||||
.detail-wrap .dt-btn-copy svg {
|
||||
color: #2a69de;
|
||||
@@ -18292,7 +18294,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-gray:hover {
|
||||
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
|
||||
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
|
||||
}
|
||||
.detail-wrap .dt-btn-red {
|
||||
width: 156px;
|
||||
@@ -18310,7 +18312,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-red:hover {
|
||||
background: rgb(100%, 27.4868759774%, 25.1921568627%);
|
||||
background: rgb(255, 70.0915337423, 64.24);
|
||||
}
|
||||
.detail-wrap .dt-btn-blue {
|
||||
width: 156px;
|
||||
@@ -19884,7 +19886,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-list:hover {
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
}
|
||||
.btn-inquiry-list:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19917,7 +19919,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-edit:hover {
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
background: rgb(0, 69.35, 171);
|
||||
}
|
||||
.btn-inquiry-edit:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19950,7 +19952,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-delete:hover {
|
||||
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
|
||||
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
|
||||
}
|
||||
.btn-inquiry-delete:active {
|
||||
transform: scale(0.98);
|
||||
@@ -20022,7 +20024,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline:hover {
|
||||
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
|
||||
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline svg {
|
||||
width: 12px;
|
||||
@@ -20054,7 +20056,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.file-upload-inline .btn-file-attach:hover {
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
}
|
||||
.file-upload-inline .btn-file-attach svg {
|
||||
width: 22px;
|
||||
@@ -20114,7 +20116,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-secondary:hover {
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary {
|
||||
background: #0049b4;
|
||||
@@ -20122,7 +20124,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary:hover {
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
background: rgb(0, 69.35, 171);
|
||||
}
|
||||
.inquiry-form-container .file-upload-inline .file-input-display {
|
||||
min-height: 50px;
|
||||
@@ -20911,7 +20913,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
cursor: pointer;
|
||||
}
|
||||
.djb-board-write-container .form-actions .btn-submit:hover {
|
||||
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
|
||||
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.djb-board-write-container .form-actions .btn-submit {
|
||||
@@ -21447,7 +21449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.org-file-remove:hover {
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
}
|
||||
|
||||
.org-file-notice {
|
||||
@@ -22488,7 +22490,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.status-indicator.status-active {
|
||||
background-color: rgba(107, 207, 127, 0.1);
|
||||
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
}
|
||||
.status-indicator.status-active .status-dot {
|
||||
background-color: #6BCF7F;
|
||||
@@ -25273,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.api-statistics-container .statistics-date-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-statistics-container .statistics-date-range:focus-visible {
|
||||
outline: 2px solid #0049B4;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.daterangepicker.statistics-date-picker {
|
||||
z-index: 1100;
|
||||
max-width: calc(100vw - 16px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.daterangepicker.statistics-date-picker .applyBtn {
|
||||
color: #fff;
|
||||
background: #0049B4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.daterangepicker.statistics-date-picker {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: #0049B4;
|
||||
border: none;
|
||||
@@ -27323,9 +27353,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.api-status .as-maint-card.is-ongoing {
|
||||
border-left-color: var(--as-warn);
|
||||
}
|
||||
.api-status .as-maint-card .as-maint-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -27337,7 +27364,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--as-text);
|
||||
color: var(--as-info);
|
||||
}
|
||||
.api-status .as-maint-card .as-schedule {
|
||||
display: inline-flex;
|
||||
@@ -27351,10 +27378,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: var(--as-pill);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.api-status .as-maint-card.is-ongoing .as-schedule {
|
||||
color: var(--as-warn);
|
||||
background: var(--as-warn-bg);
|
||||
}
|
||||
.api-status .as-maint-card .as-maint-body {
|
||||
margin: 14px 0 12px;
|
||||
font-size: 14px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -8,7 +8,7 @@
|
||||
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
|
||||
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
|
||||
*
|
||||
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return; // 모듈 미사용 페이지
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const configuredPageSize = Number(globalThis.API_SELECTOR_PAGE_SIZE);
|
||||
const PAGE_SIZE = Number.isInteger(configuredPageSize) && configuredPageSize > 0 ? configuredPageSize : 15;
|
||||
|
||||
// DOM Elements
|
||||
const searchInput = document.getElementById('apiSearch');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
||||
Copyright (c) JS Foundation and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -8,11 +8,18 @@
|
||||
*
|
||||
* 서버(PasswordDecryptFilter)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
|
||||
*
|
||||
* 폴백: 설정이 꺼져 있거나(window.__PASSWORD_CRYPTO__.enabled=false), secure context 가 아니거나
|
||||
* (로컬 HTTP 개발환경), crypto.subtle 이 없으면 아무 일도 하지 않고 평문 그대로 전송한다.
|
||||
* 키 조회·암호화 중 오류가 나도 마찬가지다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다.
|
||||
* 암호화 엔진은 두 가지이며 봉투 형식은 동일하다.
|
||||
* - subtle : secure context(HTTPS·localhost)에서 브라우저 Web Crypto 사용. 추가 다운로드 없음.
|
||||
* - forge : 원격 오리진 HTTP 처럼 crypto.subtle 이 없는 환경에서 순수 JS 구현 사용.
|
||||
* lib/forge-crypto.min.js 를 그때만 동적으로 내려받는다
|
||||
* (window.__PASSWORD_CRYPTO__.softwareFallback 로 끌 수 있다).
|
||||
*
|
||||
* 주의: 이 조치는 XSS 방어가 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있다.
|
||||
* 폴백: 설정이 꺼져 있거나, 두 엔진 모두 쓸 수 없거나, 키 조회·암호화 중 오류가 나면 아무 일도 하지 않고
|
||||
* 평문 그대로 전송한다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다. 서버 정책이 ENFORCE 면 그 평문이
|
||||
* 거부되어 인증 실패로 이어진다.
|
||||
*
|
||||
* 주의: 이 조치는 XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있고,
|
||||
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. 그래서 HTTP 접속에는 경고 팝업을 띄운다.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
@@ -20,26 +27,52 @@
|
||||
var cfg = global.__PASSWORD_CRYPTO__ || {};
|
||||
var PREFIX = 'ENC1.';
|
||||
|
||||
/** 봉투를 만들 수 있는 환경에서 전송로 경고를 세션당 한 번만 띄우기 위한 sessionStorage 키 */
|
||||
var WARN_KEY = 'portal.passwordCrypto.insecureWarned';
|
||||
|
||||
// ---------- 엔진 판정 ----------
|
||||
|
||||
/**
|
||||
* Web Crypto 사용 가능 여부. 브라우저는 secure context 가 아니면 crypto.subtle 자체를 노출하지 않는다.
|
||||
* 반면 crypto.getRandomValues 는 비 secure context 에서도 쓸 수 있어 forge 경로의 난수원으로 쓴다.
|
||||
*/
|
||||
function hasSubtle() {
|
||||
return !!(global.isSecureContext && global.crypto && global.crypto.subtle);
|
||||
}
|
||||
|
||||
function hasForge() {
|
||||
return !!(cfg.softwareFallback && cfg.forgeUrl && global.document);
|
||||
}
|
||||
|
||||
/** 설정·브라우저 조건상 봉투를 만들 수 있는가. 실제 forge 스크립트 로드는 사용 시점에 한다. */
|
||||
function available() {
|
||||
return !!(cfg.enabled
|
||||
&& global.isSecureContext
|
||||
&& cfg.keyUrl
|
||||
&& global.crypto
|
||||
&& global.crypto.subtle
|
||||
&& global.crypto.getRandomValues
|
||||
&& global.fetch
|
||||
&& global.Promise);
|
||||
&& global.Promise
|
||||
&& (hasSubtle() || hasForge()));
|
||||
}
|
||||
|
||||
// ---------- 인코딩 유틸 ----------
|
||||
|
||||
function toBase64Url(buffer) {
|
||||
var bytes = new Uint8Array(buffer);
|
||||
function bytesToBinary(bytes) {
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
function binaryToBase64Url(binary) {
|
||||
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function toBase64Url(buffer) {
|
||||
return binaryToBase64Url(bytesToBinary(new Uint8Array(buffer)));
|
||||
}
|
||||
|
||||
function base64ToBytes(base64) {
|
||||
var binary = global.atob(base64);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
@@ -49,19 +82,161 @@
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** forge 는 바이트를 binary string 으로 다룬다. 난수는 항상 브라우저 CSPRNG 에서 받는다. */
|
||||
function randomBinary(length) {
|
||||
return bytesToBinary(global.crypto.getRandomValues(new Uint8Array(length)));
|
||||
}
|
||||
|
||||
// ---------- 엔진 구현 ----------
|
||||
|
||||
/**
|
||||
* 엔진 계약:
|
||||
* importKey(base64Spki) -> Promise<publicKey>
|
||||
* seal(publicKey, keyId, names, values) -> Promise<{이름: 봉투}>
|
||||
* 두 엔진의 봉투 형식·알고리즘 파라미터는 동일해야 한다. 서버(PasswordEnvelopeCodec)는
|
||||
* RSA-OAEP 의 MGF1 해시까지 SHA-256 으로 고정돼 있으므로 forge 쪽도 mgf1 을 명시해야 한다.
|
||||
*/
|
||||
var subtleEngine = {
|
||||
name: 'subtle',
|
||||
importKey: function (base64Spki) {
|
||||
return global.crypto.subtle.importKey(
|
||||
'spki',
|
||||
base64ToBytes(base64Spki),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
},
|
||||
seal: function (publicKey, keyId, names, values) {
|
||||
var subtle = global.crypto.subtle;
|
||||
var context = {};
|
||||
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt'])
|
||||
.then(function (aesKey) {
|
||||
context.aesKey = aesKey;
|
||||
return subtle.exportKey('raw', aesKey);
|
||||
})
|
||||
.then(function (rawAesKey) {
|
||||
return subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, rawAesKey);
|
||||
})
|
||||
.then(function (wrapped) {
|
||||
var wrappedKey = toBase64Url(wrapped);
|
||||
return global.Promise.all(names.map(function (name) {
|
||||
var iv = global.crypto.getRandomValues(new Uint8Array(12));
|
||||
return subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
|
||||
context.aesKey,
|
||||
new TextEncoder().encode(values[name])
|
||||
).then(function (cipherText) {
|
||||
return {
|
||||
name: name,
|
||||
envelope: PREFIX + keyId + '.' + wrappedKey
|
||||
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
|
||||
};
|
||||
});
|
||||
}));
|
||||
})
|
||||
.then(collectEnvelopes);
|
||||
}
|
||||
};
|
||||
|
||||
var forgeEngine = {
|
||||
name: 'forge',
|
||||
importKey: function (base64Spki) {
|
||||
return loadForge().then(function (forge) {
|
||||
var der = forge.util.createBuffer(global.atob(base64Spki));
|
||||
return forge.pki.publicKeyFromAsn1(forge.asn1.fromDer(der));
|
||||
});
|
||||
},
|
||||
seal: function (publicKey, keyId, names, values) {
|
||||
return loadForge().then(function (forge) {
|
||||
var aesKey = randomBinary(32);
|
||||
// forge 의 OAEP 시드도 브라우저 CSPRNG 로 준다. forge 내장 PRNG 를 쓰지 않기 위함이다.
|
||||
var wrappedKey = binaryToBase64Url(publicKey.encrypt(aesKey, 'RSA-OAEP', {
|
||||
md: forge.md.sha256.create(),
|
||||
mgf1: { md: forge.md.sha256.create() },
|
||||
seed: randomBinary(32)
|
||||
}));
|
||||
return names.map(function (name) {
|
||||
var iv = randomBinary(12);
|
||||
var cipher = forge.cipher.createCipher('AES-GCM', aesKey);
|
||||
cipher.start({ iv: iv, tagLength: 128 });
|
||||
cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(values[name])));
|
||||
cipher.finish();
|
||||
// 서버는 Java 관례대로 "암호문 + 태그" 가 이어 붙은 형태를 기대한다.
|
||||
var payload = cipher.output.getBytes() + cipher.mode.tag.getBytes();
|
||||
return {
|
||||
name: name,
|
||||
envelope: PREFIX + keyId + '.' + wrappedKey
|
||||
+ '.' + binaryToBase64Url(iv) + '.' + binaryToBase64Url(payload)
|
||||
};
|
||||
});
|
||||
}).then(collectEnvelopes);
|
||||
}
|
||||
};
|
||||
|
||||
function collectEnvelopes(results) {
|
||||
var out = {};
|
||||
results.forEach(function (result) {
|
||||
out[result.name] = result.envelope;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 현재 환경에서 쓸 엔진. 페이지 수명 동안 바뀌지 않는다. */
|
||||
function engine() {
|
||||
if (hasSubtle()) {
|
||||
return subtleEngine;
|
||||
}
|
||||
return hasForge() ? forgeEngine : null;
|
||||
}
|
||||
|
||||
// ---------- forge 동적 로드 ----------
|
||||
|
||||
var forgeLoading = null;
|
||||
|
||||
/**
|
||||
* forge 번들은 crypto.subtle 이 없을 때만 내려받는다. HTTPS 운영 환경에서는 전송 바이트가 0 이다.
|
||||
* 한 번 시작한 로드는 재사용하고, 실패하면 호출자가 평문 폴백으로 처리한다.
|
||||
*/
|
||||
function loadForge() {
|
||||
if (global.forge && global.forge.pki && global.forge.cipher) {
|
||||
return global.Promise.resolve(global.forge);
|
||||
}
|
||||
if (forgeLoading) {
|
||||
return forgeLoading;
|
||||
}
|
||||
forgeLoading = new global.Promise(function (resolve, reject) {
|
||||
var script = global.document.createElement('script');
|
||||
script.src = cfg.forgeUrl;
|
||||
script.async = true;
|
||||
script.onload = function () {
|
||||
if (global.forge && global.forge.pki && global.forge.cipher) {
|
||||
resolve(global.forge);
|
||||
} else {
|
||||
reject(new Error('forge 번들이 전역을 노출하지 않음'));
|
||||
}
|
||||
};
|
||||
script.onerror = function () {
|
||||
reject(new Error('forge 번들 로드 실패: ' + cfg.forgeUrl));
|
||||
};
|
||||
global.document.head.appendChild(script);
|
||||
});
|
||||
return forgeLoading;
|
||||
}
|
||||
|
||||
// ---------- 키 조회 ----------
|
||||
|
||||
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
|
||||
var cachedKey = null;
|
||||
|
||||
/**
|
||||
* 서버에서 공개키를 받아 import 한다.
|
||||
* 서버에서 공개키를 받아 현재 엔진 형식으로 import 한다.
|
||||
*
|
||||
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
|
||||
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
|
||||
* 생성하게 만들지 않기 위함이다.
|
||||
*/
|
||||
function loadKey() {
|
||||
function loadKey(currentEngine) {
|
||||
if (cachedKey && cachedKey.expiresAt > Date.now()) {
|
||||
return global.Promise.resolve(cachedKey);
|
||||
}
|
||||
@@ -79,13 +254,7 @@
|
||||
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
|
||||
throw new Error('전송암호화 비활성 상태의 응답');
|
||||
}
|
||||
return global.crypto.subtle.importKey(
|
||||
'spki',
|
||||
base64ToBytes(data.publicKey),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['encrypt']
|
||||
).then(function (publicKey) {
|
||||
return currentEngine.importKey(data.publicKey).then(function (publicKey) {
|
||||
var key = { keyId: data.keyId, publicKey: publicKey };
|
||||
if (data.keyScope && data.keyScope !== 'REQUEST') {
|
||||
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
|
||||
@@ -112,45 +281,20 @@
|
||||
var v = values[name];
|
||||
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
|
||||
});
|
||||
if (!available() || names.length === 0) {
|
||||
var currentEngine = available() ? engine() : null;
|
||||
if (!currentEngine || names.length === 0) {
|
||||
return global.Promise.resolve(values || {});
|
||||
}
|
||||
|
||||
var subtle = global.crypto.subtle;
|
||||
var context = {};
|
||||
|
||||
return loadKey().then(function (key) {
|
||||
context.keyId = key.keyId;
|
||||
context.publicKey = key.publicKey;
|
||||
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
|
||||
}).then(function (aesKey) {
|
||||
context.aesKey = aesKey;
|
||||
return subtle.exportKey('raw', aesKey);
|
||||
}).then(function (rawAesKey) {
|
||||
return subtle.encrypt({ name: 'RSA-OAEP' }, context.publicKey, rawAesKey);
|
||||
}).then(function (wrapped) {
|
||||
context.wrappedKey = toBase64Url(wrapped);
|
||||
return global.Promise.all(names.map(function (name) {
|
||||
var iv = global.crypto.getRandomValues(new Uint8Array(12));
|
||||
return subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
|
||||
context.aesKey,
|
||||
new TextEncoder().encode(values[name])
|
||||
).then(function (cipherText) {
|
||||
return {
|
||||
name: name,
|
||||
envelope: PREFIX + context.keyId + '.' + context.wrappedKey
|
||||
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
|
||||
};
|
||||
});
|
||||
}));
|
||||
}).then(function (results) {
|
||||
return loadKey(currentEngine).then(function (key) {
|
||||
return currentEngine.seal(key.publicKey, key.keyId, names, values);
|
||||
}).then(function (envelopes) {
|
||||
var out = {};
|
||||
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
|
||||
results.forEach(function (result) { out[result.name] = result.envelope; });
|
||||
Object.keys(envelopes).forEach(function (name) { out[name] = envelopes[name]; });
|
||||
return out;
|
||||
}).catch(function (error) {
|
||||
// 암호화 실패는 화면을 막지 않는다. 서버 strict 가 꺼져 있으면 평문으로 처리된다.
|
||||
// 암호화 실패는 화면을 막지 않는다. 서버 정책이 ENFORCE 가 아니면 평문으로 처리된다.
|
||||
if (global.console && global.console.warn) {
|
||||
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
|
||||
}
|
||||
@@ -158,8 +302,86 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** 원본 입력에서 name 을 잠시 떼어둔 동안 그 이름을 보관하는 표시용 속성 */
|
||||
var NAME_HOLDER = 'data-password-crypto-name';
|
||||
|
||||
/**
|
||||
* 폼의 지정 필드 값을 봉투로 치환한다. 실패해도 reject 하지 않고 평문을 남긴다.
|
||||
* 입력 요소에서 실제 <input> 하나를 고른다.
|
||||
* 같은 name 이 둘 이상이면 form.elements[name] 이 RadioNodeList 를 돌려주므로,
|
||||
* 우리가 붙인 hidden 이 아닌 실제 입력창을 골라야 한다.
|
||||
*/
|
||||
function resolveInput(el) {
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
if (el.tagName) {
|
||||
return el;
|
||||
}
|
||||
for (var i = 0; i < el.length; i++) {
|
||||
var candidate = el[i];
|
||||
if (candidate && candidate.tagName && candidate.type !== 'hidden') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 입력의 value 는 그대로 두고, name 만 같은 이름의 hidden 으로 옮겨 봉투를 싣는다.
|
||||
* @returns {Function} 원상복구 함수
|
||||
*/
|
||||
function swapToEnvelopeField(form, input, name, envelope) {
|
||||
var hidden = (form.ownerDocument || global.document).createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = name;
|
||||
hidden.value = envelope;
|
||||
hidden.setAttribute(NAME_HOLDER, name);
|
||||
|
||||
input.setAttribute(NAME_HOLDER, name);
|
||||
input.removeAttribute('name');
|
||||
form.appendChild(hidden);
|
||||
|
||||
return function restore() {
|
||||
if (hidden.parentNode) {
|
||||
hidden.parentNode.removeChild(hidden);
|
||||
}
|
||||
if (input.getAttribute(NAME_HOLDER) === name) {
|
||||
input.setAttribute('name', name);
|
||||
input.removeAttribute(NAME_HOLDER);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 제출 직후 원상복구한다. 폼 데이터 직렬화는 form.submit()/requestSubmit() 호출 시점에
|
||||
* 동기로 끝나므로, 다음 매크로태스크에서 되돌려도 봉투는 이미 전송된 뒤다.
|
||||
* 뒤로가기(bfcache) 복원 시에도 name 이 떨어진 채 남지 않도록 pageshow 에서 한 번 더 처리한다.
|
||||
*/
|
||||
function scheduleRestore(restores) {
|
||||
if (restores.length === 0) {
|
||||
return;
|
||||
}
|
||||
var done = false;
|
||||
var run = function () {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
restores.forEach(function (restore) { restore(); });
|
||||
global.removeEventListener('pageshow', run);
|
||||
};
|
||||
global.addEventListener('pageshow', run);
|
||||
global.setTimeout(run, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼의 지정 필드를 봉투로 전송한다. 실패해도 reject 하지 않고 평문을 남긴다.
|
||||
*
|
||||
* 입력창(<input type="password">)의 value 는 절대 건드리지 않는다. 브라우저 비밀번호 관리자는
|
||||
* 제출 시점의 입력값을 그대로 저장하므로 봉투로 덮어쓰면 봉투 문자열이 비밀번호로 저장되고,
|
||||
* 다음 자동완성 로그인이 깨진다. 대신 원본 입력의 name 을 잠시 떼고 같은 name 의 hidden 에
|
||||
* 봉투를 실어 보낸 뒤 제출 직후 되돌린다.
|
||||
*
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {string[]} fieldNames
|
||||
* @returns {Promise<void>}
|
||||
@@ -171,7 +393,7 @@
|
||||
var targets = {};
|
||||
var elements = {};
|
||||
fieldNames.forEach(function (name) {
|
||||
var el = form.elements ? form.elements[name] : null;
|
||||
var el = resolveInput(form.elements ? form.elements[name] : null);
|
||||
if (el && typeof el.value === 'string' && el.value.length > 0) {
|
||||
elements[name] = el;
|
||||
targets[name] = el.value;
|
||||
@@ -181,14 +403,136 @@
|
||||
return global.Promise.resolve();
|
||||
}
|
||||
return encryptValues(targets).then(function (encrypted) {
|
||||
var restores = [];
|
||||
Object.keys(elements).forEach(function (name) {
|
||||
if (encrypted[name]) {
|
||||
elements[name].value = encrypted[name];
|
||||
// 폴백으로 평문이 그대로 돌아온 경우엔 아무것도 바꾸지 않는다.
|
||||
if (encrypted[name] && encrypted[name] !== targets[name]) {
|
||||
restores.push(swapToEnvelopeField(form, elements[name], name, encrypted[name]));
|
||||
}
|
||||
});
|
||||
scheduleRestore(restores);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 전송로 경고 ----------
|
||||
|
||||
/**
|
||||
* 팝업을 띄운다. custom-popups.js 는 `const customPopups`(전역 렉시컬)로 노출돼
|
||||
* window 프로퍼티가 아니므로 식별자로 직접 확인한다. 팝업 프래그먼트가 없는 화면에서는
|
||||
* 브라우저 기본 alert 로 떨어진다.
|
||||
*/
|
||||
function showDialog(message) {
|
||||
try {
|
||||
if (typeof customPopups !== 'undefined'
|
||||
&& customPopups && typeof customPopups.showAlert === 'function'
|
||||
&& global.document.getElementById('customAlert')) {
|
||||
customPopups.showAlert(message);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// customPopups 미정의 등 — 아래 기본 alert 로 떨어진다.
|
||||
}
|
||||
global.alert(message.replace(/<br\s*\/?>/gi, '\n'));
|
||||
}
|
||||
|
||||
/** 알림 팝업이 지금 떠 있는가. custom-popups.js 는 #customAlert 를 show()/hide() 로 토글한다. */
|
||||
function isAlertOpen() {
|
||||
var el = global.document.getElementById('customAlert');
|
||||
return !!(el && el.style.display !== 'none');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 떠 있는 알림이 닫힌 뒤에 띄운다.
|
||||
*
|
||||
* 알림 팝업은 페이지 전체가 #customAlert 하나를 돌려쓰고 showAlert() 는 그 안의 메시지를 덮어쓴다.
|
||||
* 우리 경고는 load 시점이라 화면 자신의 메시지(DOMContentLoaded 에서 뜨는 로그인 실패 안내 등)보다
|
||||
* 늦게 실행되므로, 그냥 부르면 그 메시지를 지워버린다. 먼저 뜬 쪽을 존중하고 뒤에 선다.
|
||||
*
|
||||
* hideAlert() 가 300ms 애니메이션 뒤에 display 를 내리므로 폴링으로 확인한다.
|
||||
* 사용자가 계속 닫지 않으면 1분 뒤 포기한다(경고를 못 봐도 화면을 방해하지는 않는다).
|
||||
*/
|
||||
function showDialogQueued(message) {
|
||||
if (!isAlertOpen()) {
|
||||
showDialog(message);
|
||||
return;
|
||||
}
|
||||
var waited = 0;
|
||||
var timer = global.setInterval(function () {
|
||||
waited += 200;
|
||||
if (!isAlertOpen()) {
|
||||
global.clearInterval(timer);
|
||||
showDialog(message);
|
||||
} else if (waited >= 60000) {
|
||||
global.clearInterval(timer);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTPS 가 아닌 연결에서 비밀번호를 입력하려는 화면에 경고를 띄운다.
|
||||
*
|
||||
* 정책이 NONE 이면 아무것도 하지 않는다. 문구와 노출 빈도는 이 브라우저가 봉투를 만들 수 있는지로 갈린다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>봉투를 만들 수 있음 - 정보성 경고다. 값은 암호화되어 나가고, 남는 위험은 중간자가 이 스크립트
|
||||
* 자체를 바꿔치기하는 경우뿐이다. 매번 띄우면 방해만 되므로 <b>세션당 1회</b>.</li>
|
||||
* <li>봉투를 만들 수 없음 - 비밀번호가 평문으로 나가거나(permissive), 서버가 거부해 로그인 자체가
|
||||
* 안 된다(enforce). 사용자가 놓치면 안 되는 상태이므로 <b>화면을 열 때마다</b>.</li>
|
||||
* </ul>
|
||||
*/
|
||||
function warnInsecureTransport() {
|
||||
if (!cfg.enabled || !cfg.policy || cfg.policy === 'NONE') {
|
||||
return;
|
||||
}
|
||||
if (!global.location || global.location.protocol === 'https:') {
|
||||
return;
|
||||
}
|
||||
if (!global.document.querySelector('input[type="password"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var encryptable = available();
|
||||
if (encryptable && !markWarnedOnce()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message;
|
||||
if (encryptable) {
|
||||
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>로 접속했습니다.<br>'
|
||||
+ '비밀번호는 전송 전에 암호화되지만, 중간자 공격까지 막지는 못합니다.<br>'
|
||||
+ '운영 환경에서는 HTTPS 로 접속하세요.';
|
||||
} else if (cfg.policy === 'ENFORCE') {
|
||||
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
|
||||
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
|
||||
+ '서버가 암호화되지 않은 비밀번호를 거부하므로 로그인할 수 없습니다.<br>'
|
||||
+ '관리자에게 문의하세요.';
|
||||
} else {
|
||||
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
|
||||
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
|
||||
+ '비밀번호가 <strong>암호화되지 않은 상태로</strong> 전송됩니다.';
|
||||
}
|
||||
showDialogQueued(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 탭 세션에서 아직 경고를 안 띄웠으면 표시를 남기고 true 를 준다.
|
||||
* 프라이빗 모드 등으로 sessionStorage 가 막혀 있으면 true — 안 띄우는 쪽보다 매번 띄우는 쪽이 안전하다.
|
||||
*/
|
||||
function markWarnedOnce() {
|
||||
try {
|
||||
if (!global.sessionStorage) {
|
||||
return true;
|
||||
}
|
||||
if (global.sessionStorage.getItem(WARN_KEY) === '1') {
|
||||
return false;
|
||||
}
|
||||
global.sessionStorage.setItem(WARN_KEY, '1');
|
||||
} catch (e) {
|
||||
// 접근 자체가 막힌 경우. 매번 띄운다.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- 선언적 훅 ----------
|
||||
|
||||
/**
|
||||
@@ -232,19 +576,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
bindDeclarativeForms();
|
||||
warnInsecureTransport();
|
||||
}
|
||||
|
||||
// DOMContentLoaded 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
|
||||
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
|
||||
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
|
||||
// 경고 팝업도 같은 시점이어야 custom-popups.js 와 팝업 프래그먼트가 준비된 뒤에 뜬다.
|
||||
if (global.document) {
|
||||
if (global.document.readyState === 'complete') {
|
||||
bindDeclarativeForms();
|
||||
start();
|
||||
} else {
|
||||
global.addEventListener('load', bindDeclarativeForms);
|
||||
global.addEventListener('load', start);
|
||||
}
|
||||
}
|
||||
|
||||
global.portalPasswordCrypto = {
|
||||
available: available,
|
||||
engine: function () {
|
||||
var current = available() ? engine() : null;
|
||||
return current ? current.name : null;
|
||||
},
|
||||
encryptForm: encryptForm,
|
||||
encryptValues: encryptValues
|
||||
};
|
||||
|
||||
@@ -91,7 +91,10 @@
|
||||
var seg = document.getElementById('tfaSegment');
|
||||
seg.innerHTML = '';
|
||||
self._maskedByType = {};
|
||||
var channels = info.channels || [];
|
||||
// SMS를 먼저 표시하고 첫 번째 수단을 기본 선택한다.
|
||||
var channels = (info.channels || []).slice().sort(function (a, b) {
|
||||
return (a.type === 'SMS' ? 0 : 1) - (b.type === 'SMS' ? 0 : 1);
|
||||
});
|
||||
channels.forEach(function (ch, idx) {
|
||||
self._maskedByType[ch.type] = ch.masked;
|
||||
var btn = document.createElement('button');
|
||||
|
||||
@@ -195,6 +195,11 @@
|
||||
color: #212529;
|
||||
font-size: 20px;
|
||||
line-height: $line-height-normal;
|
||||
// 한글은 기본값(normal)이면 음절 단위로 끊겨 "막지는 못합니 / 다" 처럼 잘린다.
|
||||
// keep-all 로 어절(띄어쓰기) 단위 줄바꿈을 강제한다. 대신 공백 없는 긴 문자열(URL·키값)이
|
||||
// 넘칠 수 있어 overflow-wrap 으로 그때만 강제 분리한다.
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
|
||||
@@ -118,6 +118,36 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.api-statistics-container .statistics-date-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #0049B4;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 달력 CSS의 z-index: 0을 이 페이지의 팝업에만 보정한다.
|
||||
.daterangepicker.statistics-date-picker {
|
||||
z-index: 1100;
|
||||
max-width: calc(100vw - 16px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.applyBtn {
|
||||
color: #fff;
|
||||
background: #0049B4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: #0049B4;
|
||||
border: none;
|
||||
@@ -692,4 +722,4 @@
|
||||
.stats-progress {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,8 +529,6 @@
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
|
||||
&.is-ongoing { border-left-color: var(--as-warn); }
|
||||
|
||||
.as-maint-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -539,7 +537,7 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-text); }
|
||||
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-info); }
|
||||
|
||||
.as-schedule {
|
||||
display: inline-flex;
|
||||
@@ -554,8 +552,6 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&.is-ongoing .as-schedule { color: var(--as-warn); background: var(--as-warn-bg); }
|
||||
|
||||
.as-maint-body { margin: 14px 0 12px; font-size: 14px; line-height: 1.6; color: var(--as-text-3); }
|
||||
|
||||
.as-maint-meta {
|
||||
|
||||
@@ -41,10 +41,11 @@
|
||||
<div class="search-filter-row">
|
||||
<!-- 일별: 날짜 범위 -->
|
||||
<div class="date-range-picker" id="dailyFilter">
|
||||
<input type="date" id="startDate" class="date-input"
|
||||
<input type="text" id="statisticsDateRange" class="date-input statistics-date-range"
|
||||
aria-label="일별 조회 기간" title="1년 전부터 오늘까지, 시작일·종료일을 포함해 최대 40일을 선택할 수 있습니다." readonly>
|
||||
<input type="hidden" id="startDate"
|
||||
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
||||
<span class="date-separator">-</span>
|
||||
<input type="date" id="endDate" class="date-input"
|
||||
<input type="hidden" id="endDate"
|
||||
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
||||
</div>
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-search" id="btnSearch">
|
||||
<button type="button" class="btn-search" id="btnSearch" aria-label="통계 조회">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
@@ -335,6 +336,8 @@
|
||||
let currentMode = 'DAILY';
|
||||
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
||||
const MAX_DATE_RANGE_DAYS = 40;
|
||||
const MIN_QUERY_DATE = moment([[${ statsMinDate }]], 'YYYY-MM-DD', true);
|
||||
const MAX_QUERY_DATE = moment([[${ statsMaxDate }]], 'YYYY-MM-DD', true);
|
||||
|
||||
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
||||
function updateChart() {
|
||||
@@ -460,16 +463,70 @@
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
// Validate date range (max 40 days)
|
||||
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
|
||||
function validateDateRange(startDate, endDate) {
|
||||
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||
var start = new Date(startDate), end = new Date(endDate);
|
||||
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
||||
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
|
||||
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
|
||||
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
if (start.isBefore(MIN_QUERY_DATE, 'day') || end.isAfter(MAX_QUERY_DATE, 'day')) {
|
||||
return { valid: false, message: '조회 날짜는 1년 전부터 오늘까지만 선택할 수 있습니다.' };
|
||||
}
|
||||
if (end.diff(start, 'days') >= MAX_DATE_RANGE_DAYS) {
|
||||
return { valid: false, message: '조회 기간은 시작일·종료일을 포함해 최대 ' + MAX_DATE_RANGE_DAYS + '일로 선택해주세요.' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function initializeDateRangePicker() {
|
||||
var input = $('#statisticsDateRange');
|
||||
function updateRangeText() {
|
||||
input.val(moment($('#startDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD') + ' - '
|
||||
+ moment($('#endDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD'));
|
||||
}
|
||||
|
||||
input.daterangepicker({
|
||||
startDate: moment($('#startDate').val(), 'YYYY-MM-DD', true),
|
||||
endDate: moment($('#endDate').val(), 'YYYY-MM-DD', true),
|
||||
minDate: MIN_QUERY_DATE.clone(),
|
||||
maxDate: MAX_QUERY_DATE.clone(),
|
||||
maxSpan: { days: MAX_DATE_RANGE_DAYS - 1 },
|
||||
autoUpdateInput: false,
|
||||
opens: 'left',
|
||||
drops: 'auto',
|
||||
locale: {
|
||||
format: 'YYYY.MM.DD',
|
||||
separator: ' - ',
|
||||
applyLabel: '조회',
|
||||
cancelLabel: '취소',
|
||||
daysOfWeek: ['일', '월', '화', '수', '목', '금', '토'],
|
||||
monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
|
||||
firstDay: 0
|
||||
}
|
||||
});
|
||||
input.data('daterangepicker').container.addClass('statistics-date-picker');
|
||||
updateRangeText();
|
||||
|
||||
input.on('show.daterangepicker', function (event, picker) {
|
||||
// 조회하지 않고 바깥을 눌러 닫았다면 확정된 조회 조건으로 다시 연다.
|
||||
picker.setStartDate(moment($('#startDate').val(), 'YYYY-MM-DD', true));
|
||||
picker.setEndDate(moment($('#endDate').val(), 'YYYY-MM-DD', true));
|
||||
picker.updateView();
|
||||
});
|
||||
input.on('apply.daterangepicker', function (event, picker) {
|
||||
$('#startDate').val(picker.startDate.format('YYYY-MM-DD'));
|
||||
$('#endDate').val(picker.endDate.format('YYYY-MM-DD'));
|
||||
updateRangeText();
|
||||
searchStatistics();
|
||||
});
|
||||
input.on('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
input.data('daterangepicker').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
mode: currentMode,
|
||||
@@ -505,8 +562,10 @@
|
||||
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
||||
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
||||
}
|
||||
if (p.startDate) $('#startDate').val(p.startDate);
|
||||
if (p.endDate) $('#endDate').val(p.endDate);
|
||||
if (validateDateRange(p.startDate, p.endDate).valid) {
|
||||
$('#startDate').val(p.startDate);
|
||||
$('#endDate').val(p.endDate);
|
||||
}
|
||||
if (p.month) $('#month').val(p.month);
|
||||
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
||||
return true;
|
||||
@@ -563,12 +622,12 @@
|
||||
// Event Handlers
|
||||
$('#btnSearch').on('click', searchStatistics);
|
||||
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
||||
$('#startDate, #endDate').on('change', searchStatistics);
|
||||
$('#month').on('change', searchStatistics);
|
||||
$('#startDate, #endDate').on('keypress', function (e) { if (e.which === 13) searchStatistics(); });
|
||||
|
||||
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
|
||||
if (restoreParams()) {
|
||||
var restored = restoreParams();
|
||||
initializeDateRangePicker();
|
||||
if (restored) {
|
||||
searchStatistics();
|
||||
}
|
||||
});
|
||||
@@ -577,4 +636,4 @@
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
||||
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
||||
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
|
||||
클라이언트에서 12건/페이지로 페이징(#apiPagination, api-selector.js PAGE_SIZE) — 전체선택/모달은 페이징과
|
||||
PTL_PROPERTY(Portal / api.list.page-size, 기본 15) 기준으로 클라이언트 페이징 — 전체선택/모달은 페이징과
|
||||
무관하게 필터된 전체 목록 기준으로 동작.
|
||||
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
|
||||
*/-->
|
||||
@@ -107,6 +107,7 @@
|
||||
<script th:inline="javascript">
|
||||
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
|
||||
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
||||
window.API_SELECTOR_PAGE_SIZE = /*[[${@apiListProperties.pageSize}]]*/ 15;
|
||||
</script>
|
||||
<script th:src="@{/js/api-selector.js}"></script>
|
||||
</th:block>
|
||||
|
||||
@@ -32,12 +32,16 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 브라우저가 Web Crypto 를
|
||||
쓸 수 없으면(로컬 HTTP 등) 모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다. -->
|
||||
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 봉투를 만들 수단이 전혀 없으면
|
||||
모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다.
|
||||
forgeUrl 은 crypto.subtle 을 못 쓰는 환경(원격 오리진 HTTP)에서만 동적으로 로드된다. -->
|
||||
<script th:inline="javascript">
|
||||
window.__PASSWORD_CRYPTO__ = {
|
||||
enabled: /*[[${passwordCryptoEnabled}]]*/ false,
|
||||
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json'
|
||||
enabled: /*[[${passwordCrypto.enabled}]]*/ false,
|
||||
policy: /*[[${passwordCrypto.policy}]]*/ 'NONE',
|
||||
softwareFallback: /*[[${passwordCrypto.softwareFallback}]]*/ true,
|
||||
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json',
|
||||
forgeUrl: /*[[@{/js/lib/forge-crypto.min.js}]]*/ '/js/lib/forge-crypto.min.js'
|
||||
};
|
||||
</script>
|
||||
<script th:src="@{/js/password-crypto.js}"></script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- fragment/popup/customPopups.html -->
|
||||
<div th:fragment="customPopups">
|
||||
<div th:replace="fragment/popup/customPopup :: #customAlert"></div>
|
||||
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
|
||||
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
|
||||
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
|
||||
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
|
||||
<div th:replace="~{fragment/popup/customPopup :: #customAlert}"></div>
|
||||
<div th:replace="~{fragment/popup/emailValidationPopup :: #emailValidationPopup}"></div>
|
||||
<div th:replace="~{fragment/popup/customPopup2 :: #customConfirm}"></div>
|
||||
<div th:replace="~{fragment/popup/passwordInputPopup :: passwordInputPopup}"></div>
|
||||
<div th:replace="~{fragment/popup/terminateRequestPopup :: terminateRequestPopup}"></div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
|
||||
<head th:replace="fragment/head :: headFragment">
|
||||
<head th:replace="~{fragment/head :: headFragment}">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
||||
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
<head th:replace="fragment/djbank/head :: headFragment">
|
||||
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<th:block layout:fragment="contentScript">
|
||||
</th:block>
|
||||
|
||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
||||
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||
<section layout:fragment="pagePopups"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
|
||||
<head th:replace="fragment/djbank/head :: headFragment">
|
||||
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<th:block layout:fragment="contentScript">
|
||||
</th:block>
|
||||
|
||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
||||
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
<head th:replace="fragment/djbank/head :: headFragment">
|
||||
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<th:block layout:fragment="script">
|
||||
</th:block>
|
||||
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
<head th:replace="fragment/djbank/head :: headFragment">
|
||||
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="loading-overlay" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
<div class="navigation" th:replace="fragment/djbank/header_nav :: headerFragment"></div>
|
||||
<div class="navigation" th:replace="~{fragment/djbank/header_nav :: headerFragment}"></div>
|
||||
<div class="container">
|
||||
<th:block layout:fragment="contentFragment">
|
||||
</th:block>
|
||||
@@ -21,9 +21,9 @@
|
||||
<th:block layout:fragment="contentScript">
|
||||
</th:block>
|
||||
|
||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
||||
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||
<section layout:fragment="pagePopups"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
|
||||
<head th:replace="fragment/head :: headFragment">
|
||||
<head th:replace="~{fragment/head :: headFragment}">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
||||
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,31 +3,31 @@
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
|
||||
<head th:replace="fragment/head :: headFragment">
|
||||
<head th:replace="~{fragment/head :: headFragment}">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- header -->
|
||||
<header th:replace="fragment/admin_header :: headerFragment"></header>
|
||||
<th:block th:replace="fragment/admin_header :: headerScript"></th:block>
|
||||
<nav th:replace="fragment/header :: navFragment"></nav>
|
||||
<header th:replace="~{fragment/admin_header :: headerFragment}"></header>
|
||||
<th:block th:replace="~{fragment/admin_header :: headerScript}"></th:block>
|
||||
<nav th:replace="~{fragment/header :: navFragment}"></nav>
|
||||
|
||||
|
||||
|
||||
<!-- csrf -->
|
||||
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<div>
|
||||
<aside th:replace="fragment/menu :: menuFragment"></aside>
|
||||
<th:block th:replace="fragment/menu :: menuScript"></th:block>
|
||||
<aside th:replace="~{fragment/menu :: menuFragment}"></aside>
|
||||
<th:block th:replace="~{fragment/menu :: menuScript}"></th:block>
|
||||
|
||||
<section class="content">
|
||||
<div layout:fragment="contentFragment">
|
||||
</div>
|
||||
<th:block layout:fragment="contentScript">
|
||||
</th:block>
|
||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
||||
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<div class="example">
|
||||
<h2>2. 프래그먼트 대체</h2>
|
||||
<header th:replace="fragment/header :: headerFragment"></header>
|
||||
<header th:replace="~{fragment/header :: headerFragment}"></header>
|
||||
</div>
|
||||
|
||||
<div class="example">
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
|
||||
<div class="example">
|
||||
<h2>14. 프래그먼트</h2>
|
||||
<div th:include="fragment/footer :: footerFragment">여기에 푸터가 들어갑니다</div>
|
||||
<div th:include="~{fragment/footer :: footerFragment}">여기에 푸터가 들어갑니다</div>
|
||||
</div>
|
||||
|
||||
<div class="example">
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.eactive.apim.portal.apps.apis.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.ui.ExtendedModelMap;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ApiControllerTest {
|
||||
|
||||
@Mock private ApiService apiService;
|
||||
@Mock private ApiServiceService apiServiceService;
|
||||
@Mock private ApiSearchFacade apiSearchFacade;
|
||||
@Mock private ApiStatusCatalogService apiStatusCatalogService;
|
||||
@Mock private PortalPropertyService portalPropertyService;
|
||||
private ApiController controller;
|
||||
|
||||
private final List<ApiSpecInfoDto> apis = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new ApiController(apiService, apiServiceService, apiSearchFacade,
|
||||
apiStatusCatalogService, new ApiListProperties(portalPropertyService));
|
||||
for (int i = 0; i < 32; i++) {
|
||||
ApiSpecInfoDto api = new ApiSpecInfoDto();
|
||||
api.setApiId("api-" + i);
|
||||
apis.add(api);
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("apis", apis);
|
||||
result.put("totalApiCount", apis.size());
|
||||
result.put("selectedApiCount", apis.size());
|
||||
when(apiSearchFacade.searchApis(any(ApiGroupSearch.class))).thenReturn(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultSizePaginatesByFifteen() {
|
||||
stubPageSize("15");
|
||||
|
||||
Page<?> first = listPage(0);
|
||||
assertEquals(15, first.getNumberOfElements());
|
||||
assertEquals(3, first.getTotalPages());
|
||||
assertEquals(32, first.getTotalElements());
|
||||
assertEquals(apis.subList(0, 15), first.getContent());
|
||||
assertEquals(apis.subList(15, 30), listPage(1).getContent());
|
||||
assertEquals(apis.subList(30, 32), listPage(2).getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void propertyChangesApplyOnNextRequestAndOverrideRequestSize() {
|
||||
stubPageSize(" 6 ");
|
||||
Page<?> page = listPage(1);
|
||||
assertEquals(6, page.getSize());
|
||||
assertEquals(apis.subList(6, 12), page.getContent());
|
||||
|
||||
stubPageSize("20");
|
||||
assertEquals(apis.subList(20, 32), listPage(1).getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidPropertyFallsBackToFifteen() {
|
||||
for (String value : new String[] {null, "", " ", "0", "-1", "invalid", "1.5", "2147483648"}) {
|
||||
stubPageSize(value);
|
||||
assertEquals(15, listPage(0).getSize(), "설정값: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void largePageOffsetDoesNotOverflow() {
|
||||
stubPageSize("15");
|
||||
assertTrue(listPage(Integer.MAX_VALUE - 1).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySearchResultsProduceEmptyPage() {
|
||||
stubPageSize("15");
|
||||
apis.clear();
|
||||
Page<?> page = listPage(0);
|
||||
assertTrue(page.isEmpty());
|
||||
assertEquals(0, page.getTotalElements());
|
||||
}
|
||||
|
||||
private void stubPageSize(String value) {
|
||||
when(portalPropertyService.getOrCreateProperty(
|
||||
eq("Portal"), eq("api.list.page-size"), eq("15"), anyString())).thenReturn(value);
|
||||
}
|
||||
|
||||
private Page<?> listPage(int pageNumber) {
|
||||
ExtendedModelMap model = new ExtendedModelMap();
|
||||
assertEquals("apps/apis/mainApiList", controller.apiList(
|
||||
new ApiGroupSearch(), PageRequest.of(pageNumber, 10), model));
|
||||
Page<?> page = (Page<?>) model.get("page");
|
||||
assertEquals(page.getContent(), model.get("apis"));
|
||||
return page;
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.eactive.apim.portal.apps.statistics.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
|
||||
import com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
class ApiStatisticsDateRangeTest {
|
||||
|
||||
private ApiStatisticsService service;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
service = mock(ApiStatisticsService.class);
|
||||
PortalOrg org = mock(PortalOrg.class);
|
||||
when(org.getId()).thenReturn("test-org");
|
||||
PortalAuthenticatedUser user = mock(PortalAuthenticatedUser.class);
|
||||
when(user.getPortalOrg()).thenReturn(org);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null));
|
||||
mvc = MockMvcBuilders.standaloneSetup(new ApiStatisticsController(service)).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOutOfWindowAnd41DayRangesForSearchAndDownload() throws Exception {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate old = today.minusYears(1).minusDays(1);
|
||||
LocalDate future = today.plusDays(1);
|
||||
LocalDate[][] invalid = {{old, old}, {future, future}, {today.minusDays(40), today}};
|
||||
for (LocalDate[] range : invalid) {
|
||||
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload(range[0], range[1])))
|
||||
.andExpect(status().isBadRequest());
|
||||
mvc.perform(get("/statistics/api/download")
|
||||
.param("startDate", range[0].toString()).param("endDate", range[1].toString()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accepts40InclusiveDaysAndInvokesStatisticsService() throws Exception {
|
||||
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
|
||||
LocalDate today = LocalDate.now();
|
||||
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload(today.minusDays(39), today)))
|
||||
.andExpect(status().isOk());
|
||||
verify(service).getStatistics(eq("test-org"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersCalendarBoundsFromServerDate() throws Exception {
|
||||
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
|
||||
LocalDate today = LocalDate.now();
|
||||
mvc.perform(get("/statistics/api"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(model().attribute("statsMinDate", today.minusYears(1).toString()))
|
||||
.andExpect(model().attribute("statsMaxDate", today.toString()));
|
||||
}
|
||||
|
||||
private String payload(LocalDate start, LocalDate end) {
|
||||
return "{\"mode\":\"DAILY\",\"startDate\":\"" + start + "\",\"endDate\":\"" + end + "\"}";
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.eactive.apim.portal.apps.statistics.dto;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ApiStatisticsSearchDtoTest {
|
||||
|
||||
private boolean valid(String start, String end, String today) {
|
||||
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
|
||||
dto.setStartDate(start == null ? null : LocalDate.parse(start));
|
||||
dto.setEndDate(end == null ? null : LocalDate.parse(end));
|
||||
return dto.isValidDateRange(LocalDate.parse(today));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsTodayAndExactlyOneYearAgoButRejectsOutsideDates() {
|
||||
assertTrue(valid("2026-09-09", "2026-09-09", "2026-09-09"));
|
||||
assertTrue(valid("2025-09-09", "2025-09-09", "2026-09-09"));
|
||||
assertFalse(valid("2025-09-08", "2025-09-08", "2026-09-09"));
|
||||
assertFalse(valid("2026-09-10", "2026-09-10", "2026-09-09"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsBothEndpointsAndHandlesLeapDays() {
|
||||
assertTrue(valid("2026-08-01", "2026-09-09", "2026-09-09"));
|
||||
assertFalse(valid("2026-07-31", "2026-09-09", "2026-09-09"));
|
||||
assertTrue(valid("2024-02-10", "2024-03-20", "2024-04-10"));
|
||||
assertFalse(valid("2024-02-09", "2024-03-20", "2024-04-10"));
|
||||
assertTrue(valid("2023-02-28", "2023-02-28", "2024-02-29"));
|
||||
assertFalse(valid("2023-02-27", "2023-02-27", "2024-02-29"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingOrReversedDates() {
|
||||
assertFalse(valid(null, "2026-09-09", "2026-09-09"));
|
||||
assertFalse(valid("2026-09-09", null, "2026-09-09"));
|
||||
assertFalse(valid("2026-09-09", "2026-09-08", "2026-09-09"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicValidatorUsesCurrentServerDate() {
|
||||
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
|
||||
dto.setStartDate(LocalDate.now());
|
||||
dto.setEndDate(LocalDate.now());
|
||||
assertTrue(dto.isValidDateRange());
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package com.eactive.apim.portal.common.compatibility;
|
||||
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.StaticMessageSource;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.support.RequestDataValueProcessor;
|
||||
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
|
||||
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||
import org.thymeleaf.spring5.view.ThymeleafView;
|
||||
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ThymeleafBootMvcCompatibilityTest {
|
||||
|
||||
// No user-defined engine/resolver: Boot must create and initialize both.
|
||||
private final WebApplicationContextRunner runner = new WebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, ThymeleafAutoConfiguration.class))
|
||||
.withUserConfiguration(SupportConfiguration.class)
|
||||
.withPropertyValues("spring.thymeleaf.prefix=classpath:/templates/",
|
||||
"spring.thymeleaf.suffix=.html", "spring.thymeleaf.encoding=UTF-8", "spring.thymeleaf.cache=false");
|
||||
|
||||
@AfterEach
|
||||
void clearThreadContexts() {
|
||||
SecurityContextHolder.clearContext();
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bootAutoConfigurationInitializesAndRendersLayout() {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(SpringTemplateEngine.class)
|
||||
.hasSingleBean(ThymeleafViewResolver.class);
|
||||
SpringTemplateEngine engine = context.getBean(SpringTemplateEngine.class);
|
||||
assertThat(engine.getConfiguration()).isNotNull();
|
||||
assertThat(engine.getDialects()).anyMatch(LayoutDialect.class::isInstance)
|
||||
.anyMatch(SpringSecurityDialect.class::isInstance);
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("name", "portal");
|
||||
model.put("date", java.time.LocalDateTime.of(2026, 9, 7, 12, 0));
|
||||
assertThat(render(context, "compatibility/page", model))
|
||||
.contains("layout-shell", "<b>PORTAL</b>", "2026.09.07").doesNotContain(">default</main>");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void mvcFormKeepsBindingSelectionErrorsMessagesAndRequestDataProcessing() {
|
||||
runner.run(context -> {
|
||||
Form form = new Form();
|
||||
BindingResult errors = new BeanPropertyBindingResult(form, "form");
|
||||
errors.rejectValue("email", "email.invalid");
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("form", form);
|
||||
model.put(BindingResult.MODEL_KEY_PREFIX + "form", errors);
|
||||
String html = render(context, "compatibility/form", model);
|
||||
assertThat(html).contains("가입 정보", "이메일 확인 필요", "name=\"email\"", "value=\"user@example.com\"",
|
||||
"action=\"/portal/submit\"", "name=\"role\"", "value=\"USER\" selected=\"selected\"",
|
||||
"name=\"_csrf\"", "value=\"compat-token\"")
|
||||
.doesNotContain("value=\"ADMIN\" selected=\"selected\"", "th:field", "th:errors");
|
||||
RecordingProcessor processor = context.getBean(RecordingProcessor.class);
|
||||
assertThat(processor.fieldTypes).containsExactly("email", "option", "option");
|
||||
assertThat(processor.actions).containsExactly("POST /portal/submit");
|
||||
|
||||
// A different selection and a valid form must clear the error and old selection.
|
||||
form.setRole("ADMIN");
|
||||
model.put(BindingResult.MODEL_KEY_PREFIX + "form", new BeanPropertyBindingResult(form, "form"));
|
||||
assertThat(render(context, "compatibility/form", model))
|
||||
.contains("value=\"ADMIN\" selected=\"selected\"")
|
||||
.doesNotContain("이메일 확인 필요", "value=\"USER\" selected=\"selected\"");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousUserSeesOnlyAnonymousContent() {
|
||||
SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken(
|
||||
"compatibility", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
|
||||
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||
.contains("로그인 안내").doesNotContain("회원 메뉴", "관리자 메뉴", "id=\"identity\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedUserSeesIdentityAndUserContent() {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
"portal-user", "unused", AuthorityUtils.createAuthorityList("ROLE_USER")));
|
||||
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||
.contains("회원 메뉴", ">portal-user</span>").doesNotContain("로그인 안내", "관리자 메뉴"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void administratorSeesRoleProtectedContent() {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
"portal-admin", "unused", AuthorityUtils.createAuthorityList("ROLE_ADMIN")));
|
||||
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||
.contains("회원 메뉴", "관리자 메뉴", ">portal-admin</span>").doesNotContain("로그인 안내"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiSelectorUsesSharedPageSizeForClientAndWebhookRegistrationAndModification() {
|
||||
PortalPropertyService properties = mock(PortalPropertyService.class);
|
||||
runner.withBean("apiListProperties", ApiListProperties.class, () -> new ApiListProperties(properties))
|
||||
.run(context -> {
|
||||
for (int pageSize : new int[]{15, 6}) {
|
||||
when(properties.getOrCreateProperty(eq("Portal"), eq("api.list.page-size"),
|
||||
eq("15"), anyString())).thenReturn(String.valueOf(pageSize));
|
||||
for (String action : new String[]{"/clients/register/step2", "/clients/modify/step2",
|
||||
"/webhook/register/step2", "/webhook/modify/step2"}) {
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("apiServices", Collections.emptyList());
|
||||
model.put("selectedApis", Collections.singletonList("selected-api"));
|
||||
model.put("formAction", action);
|
||||
model.put("saveAction", action + "/save");
|
||||
String html = render(context, "views/fragment/api_selector :: apiSelector", model);
|
||||
assertThat(html).contains("window.API_SELECTOR_PAGE_SIZE = " + pageSize + ";",
|
||||
"action=\"/portal" + action + "\"",
|
||||
"window.API_SELECTOR_SELECTED = [\"selected-api\"];");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String render(WebApplicationContext context, String template, Map<String, Object> model) throws Exception {
|
||||
context.getServletContext().setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, context);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(context.getServletContext());
|
||||
request.setContextPath("/portal");
|
||||
request.setRequestURI("/portal/signup");
|
||||
request.setMethod("GET");
|
||||
request.setPreferredLocales(Collections.singletonList(Locale.KOREA));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response));
|
||||
try {
|
||||
ThymeleafViewResolver resolver = context.getBean(ThymeleafViewResolver.class);
|
||||
assertThat(resolver.resolveViewName(template, Locale.KOREA)).isInstanceOf(ThymeleafView.class);
|
||||
resolver.resolveViewName(template, Locale.KOREA).render(model, request, response);
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8");
|
||||
return response.getContentAsString();
|
||||
} finally {
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class SupportConfiguration {
|
||||
@Bean
|
||||
LayoutDialect layoutDialect() {
|
||||
return new LayoutDialect();
|
||||
}
|
||||
|
||||
@Bean
|
||||
StaticMessageSource messageSource() {
|
||||
StaticMessageSource messages = new StaticMessageSource();
|
||||
messages.addMessage("form.title", Locale.KOREA, "가입 정보");
|
||||
messages.addMessage("email.invalid", Locale.KOREA, "이메일 확인 필요");
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Bean
|
||||
RecordingProcessor requestDataValueProcessor() {
|
||||
return new RecordingProcessor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
DefaultWebSecurityExpressionHandler webSecurityExpressionHandler() {
|
||||
return new DefaultWebSecurityExpressionHandler();
|
||||
}
|
||||
}
|
||||
|
||||
static class RecordingProcessor implements RequestDataValueProcessor {
|
||||
final List<String> fieldTypes = new ArrayList<>();
|
||||
final List<String> actions = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public String processAction(HttpServletRequest request, String action, String method) {
|
||||
actions.add(method.toUpperCase(Locale.ROOT) + " " + action);
|
||||
return action;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String processFormFieldValue(HttpServletRequest request, String name, String value, String type) {
|
||||
fieldTypes.add(type);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getExtraHiddenFields(HttpServletRequest request) {
|
||||
return Collections.singletonMap("_csrf", "compat-token");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String processUrl(HttpServletRequest request, String url) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Form {
|
||||
private String email = "user@example.com";
|
||||
private String role = "USER";
|
||||
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.eactive.apim.portal.common.compatibility;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
|
||||
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||
|
||||
/** The original 15 probe scenarios, using only classpath fixtures and production fragments. */
|
||||
class ThymeleafExpressionCompatibilityTest {
|
||||
|
||||
static Context context() {
|
||||
Context context = new Context(Locale.KOREA);
|
||||
context.setVariable("name", "portal");
|
||||
context.setVariable("items", Arrays.asList("a", "b"));
|
||||
context.setVariable("date", LocalDateTime.of(2026, 9, 7, 12, 0));
|
||||
return context;
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
Stream<DynamicTest> expressionCompatibility() {
|
||||
return Stream.of(false, true).flatMap(spring -> {
|
||||
TemplateEngine engine = spring ? new SpringTemplateEngine() : new TemplateEngine();
|
||||
engine.setTemplateResolver(new StringTemplateResolver());
|
||||
String name = spring ? "SpringEL " : "OGNL ";
|
||||
return Stream.of(
|
||||
dynamicTest(name + "initialization", () -> assertThat(engine.getConfiguration()).isNotNull()),
|
||||
dynamicTest(name + "property", () -> assertThat(engine.process(
|
||||
"<p th:text=\"${name}\"></p>", context())).isEqualTo("<p>portal</p>")),
|
||||
dynamicTest(name + "method", () -> assertThat(engine.process(
|
||||
"<p th:text=\"${name.toUpperCase()}\"></p>", context())).isEqualTo("<p>PORTAL</p>")),
|
||||
dynamicTest(name + "collection and condition", () -> assertThat(engine.process(
|
||||
"<p th:if=\"${#lists.size(items) > 1}\" th:text=\"${items[1]}\"></p>", context()))
|
||||
.isEqualTo("<p>b</p>")),
|
||||
dynamicTest(name + "date", () -> assertThat(engine.process(
|
||||
"<p th:text=\"${#temporals.format(date, 'yyyy.MM.dd')}\"></p>", context()))
|
||||
.isEqualTo("<p>2026.09.07</p>")),
|
||||
dynamicTest(name + "JavaScript inline", () -> assertThat(engine.process(
|
||||
"<script th:inline=\"javascript\">var name = [[${name}]];</script>", context()))
|
||||
.isEqualTo("<script>var name = \"portal\";</script>")));
|
||||
});
|
||||
}
|
||||
|
||||
private SpringTemplateEngine resourceEngine() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setPrefix("templates/");
|
||||
resolver.setSuffix(".html");
|
||||
resolver.setCharacterEncoding("UTF-8");
|
||||
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||
engine.setTemplateResolver(resolver);
|
||||
return engine;
|
||||
}
|
||||
|
||||
@Test
|
||||
void layoutCompositionWithSecurityDialect() {
|
||||
SpringTemplateEngine engine = resourceEngine();
|
||||
engine.addDialect(new LayoutDialect());
|
||||
engine.addDialect(new SpringSecurityDialect());
|
||||
assertThat(engine.process("compatibility/page", context()))
|
||||
.contains("layout-shell", "<b>PORTAL</b>", "<p>2026.09.07</p>")
|
||||
.doesNotContain(">default</main>", "layout:decorate", "th:text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualPaginationKeepsNumbersEventsAndBoundaryStates() {
|
||||
SpringTemplateEngine engine = resourceEngine();
|
||||
Context context = context();
|
||||
context.setVariable("jsFunction", "loadPage");
|
||||
for (int pageNumber : new int[]{0, 2, 4}) {
|
||||
context.setVariable("page", new PageImpl<>(Arrays.asList("a", "b"),
|
||||
PageRequest.of(pageNumber, 10), 50));
|
||||
String html = engine.process("views/fragment/pagination", Collections.singleton("pagination"), context);
|
||||
assertThat(html).contains("page-current\">" + (pageNumber + 1) + "</a>",
|
||||
"onclick=\"loadPage(1, 10);\"", "onclick=\"loadPage(5, 10);\"",
|
||||
"onclick=\"loadPage(" + Math.max(1, pageNumber) + ", 10);\"",
|
||||
"onclick=\"loadPage(" + Math.min(5, pageNumber + 2) + ", 10);\"");
|
||||
for (int number = 1; number <= 5; number++) {
|
||||
if (Math.abs(number - (pageNumber + 1)) <= 2) {
|
||||
assertThat(html).contains(">" + number + "</a>");
|
||||
} else {
|
||||
assertThat(html).doesNotContain(">" + number + "</a>");
|
||||
}
|
||||
}
|
||||
assertThat(html.contains("class=\"page-first disabled\"")).isEqualTo(pageNumber == 0);
|
||||
assertThat(html.contains("class=\"page-prev disabled\"")).isEqualTo(pageNumber == 0);
|
||||
assertThat(html.contains("class=\"page-next disabled\"")).isEqualTo(pageNumber == 4);
|
||||
assertThat(html.contains("class=\"page-last disabled\"")).isEqualTo(pageNumber == 4);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualSmsAndEmailNoticeVisibility() {
|
||||
SpringTemplateEngine engine = resourceEngine();
|
||||
Context context = context();
|
||||
for (String fragment : Arrays.asList("smsNotice", "emailNotice")) {
|
||||
for (boolean show : new boolean[]{true, false}) {
|
||||
for (String code : new String[]{"123456", null}) {
|
||||
context.setVariable("showTestAuthNotice", show);
|
||||
context.setVariable("testAuthNumber", code);
|
||||
String html = engine.process("views/fragment/test-env-auth-notice",
|
||||
Collections.singleton(fragment), context);
|
||||
if (show && code != null) {
|
||||
assertThat(html).contains("123456", "테스트 환경 안내", fragment.equals("smsNotice") ? "SMS" : "이메일");
|
||||
} else {
|
||||
assertThat(html).doesNotContain("123456", "test-env-notice", "테스트 환경 안내");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Moment.js 호환성 테스트
|
||||
|
||||
Node.js 20 이상에서 실행한다.
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
npm run test:moment
|
||||
```
|
||||
|
||||
Linux CI에서 브라우저 시스템 라이브러리도 설치해야 한다면
|
||||
`npx playwright install --with-deps chromium`을 사용한다.
|
||||
|
||||
Spring 서버, DB, 외부 CDN 없이 저장소의 실제 jQuery, Moment.js,
|
||||
daterangepicker.js, front2.js와 달력 CSS를 Chromium에 로드한다.
|
||||
초기화와 콜백은 제품 코드를 그대로 실행하며, 테스트에서 복제하거나 모킹하지 않는다.
|
||||
HTML fixture는 공통 스크립트에 필요한 레이아웃과 날짜 입력 필드를 제공한다.
|
||||
|
||||
서울과 뉴욕 시간대 각각에서 날짜 표시, 윤년, 월/연도 경계 이동, 선택 적용,
|
||||
취소, 같은 날 선택, 서머타임 경계의 직접 입력을 검증한다.
|
||||
CommonJS 격리 실행에서는 CVE-2022-24785의 경로 탐색 로케일이
|
||||
`require`까지 도달하지 않는지 검사한다. 브라우저의 정상 사용과 별도의 보안 회귀 검사다.
|
||||
실패 시 스크린샷과 trace는 `build/playwright`에 저장된다.
|
||||
실제 서버 통합과 전체 페이지의 시각적 배치는 이 테스트 범위에 포함되지 않는다.
|
||||
|
||||
## APP 통계 페이지
|
||||
|
||||
`statistics-date-range.spec.js`는 `apiStatistics.html`의 실제 DOM과 인라인 스크립트를
|
||||
실행한다. Thymeleaf가 주입할 초기 데이터와 통계 검색 API 응답만 테스트 값으로 대체한다.
|
||||
일별 기간 선택/조회, 취소 및 바깥 클릭, 서버 기준 1년 전~오늘 제한,
|
||||
시작일·종료일을 포함한 최대 40일 제한, 범위를 벗어난 세션 조건 폐기 및 요청 차단,
|
||||
월별 전환, 앱 선택 유지, ISO 날짜와 CSRF 헤더 전송, 모바일 표시를 검증한다.
|
||||
서버의 Thymeleaf 렌더링과 실제 통계 집계는 별도 통합 검증 대상이다.
|
||||
|
||||
통계 페이지만 실행: `npm run test:moment -- --grep 'statistics:'`
|
||||
|
||||
## 배포 파일 출처
|
||||
|
||||
- 버전: 2.30.1 (이전 버전 2.24.0)
|
||||
- 공식 파일: https://raw.githubusercontent.com/moment/moment/2.30.1/min/moment.min.js
|
||||
- SHA-256: `845c524969edd5b3af9aa6d8718d29fe92e8dbe25b955214a8e064a05a9a5027`
|
||||
- 배포 경로: `src/main/resources/static/js/moment.min.js`
|
||||
- MIT 라이선스: 동일 디렉터리의 `moment.LICENSE` (공식 태그의 LICENSE 원문)
|
||||
- 보안 공지: https://github.com/moment/moment/security/advisories/GHSA-8hfj-j24r-96c4
|
||||
|
||||
버전 업그레이드 시 공식 배포 파일과 해시를 확인하고 버전 assertion도 갱신한다.
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Portal date range compatibility</title>
|
||||
<link rel="stylesheet" href="/css/daterangepicker.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- front2.js의 공통 레이아웃 이벤트에도 실제 DOM을 제공한다. -->
|
||||
<header id="header"><nav id="nav"><div id="main_nav"></div><div id="sub"></div></nav></header>
|
||||
<main id="wrap" style="padding: 100px 400px">
|
||||
<form>
|
||||
<label>조회 기간 <input type="text" name="daterange" style="width: 240px"></label>
|
||||
<input type="hidden" name="startDate" value="2024.02.28">
|
||||
<input type="hidden" name="endDate" value="2024.03.02">
|
||||
<button class="datepicker_icon" type="button">달력 열기</button>
|
||||
</form>
|
||||
</main>
|
||||
<footer>
|
||||
<div class="family-sites"><button id="family-sites-toggle" type="button">관련 사이트</button><ul></ul></div>
|
||||
</footer>
|
||||
<!-- 제품에서 사용하는 파일과 순서 그대로 실행한다. 초기화 코드를 복제하지 않는다. -->
|
||||
<script src="/plugins/jquery/jquery-3.7.1.min.js"></script>
|
||||
<script src="/js/moment.min.js"></script>
|
||||
<script src="/js/daterangepicker.js"></script>
|
||||
<script src="/js/front2.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,161 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const staticRoot = path.resolve(__dirname, '../../main/resources/static');
|
||||
const momentSource = readFileSync(path.join(staticRoot, 'js/moment.min.js'), 'utf8');
|
||||
const fixture = readFileSync(path.join(__dirname, 'fixtures/date-range.html'), 'utf8');
|
||||
const assets = new Map([
|
||||
['/css/daterangepicker.css', 'text/css'],
|
||||
['/plugins/jquery/jquery-3.7.1.min.js', 'text/javascript'],
|
||||
['/js/moment.min.js', 'text/javascript'],
|
||||
['/js/daterangepicker.js', 'text/javascript'],
|
||||
['/js/front2.js', 'text/javascript'],
|
||||
['/img/icon/icon_close.png', 'image/png'],
|
||||
['/img/icon/icon_dp_arrow_prev.png', 'image/png'],
|
||||
['/img/icon/icon_dp_arrow_next.png', 'image/png']
|
||||
]);
|
||||
|
||||
async function openCalendar(page, start = '2024.02.28', end = '2024.03.02') {
|
||||
// 애플리케이션 서버/DB/외부 CDN 없이 저장소의 실제 정적 파일을 제공한다.
|
||||
await page.route('**/*', async route => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === '/date-range') {
|
||||
return route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: fixture.replace('value="2024.02.28"', `value="${start}"`)
|
||||
.replace('value="2024.03.02"', `value="${end}"`)
|
||||
});
|
||||
}
|
||||
if (assets.has(pathname)) {
|
||||
return route.fulfill({
|
||||
contentType: assets.get(pathname),
|
||||
path: path.join(staticRoot, pathname.slice(1))
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fixture request: ${route.request().url()}`);
|
||||
});
|
||||
await page.goto('http://portal.test/date-range');
|
||||
await expect(page.locator('[name="daterange"]')).toHaveValue(`${start} - ${end}`);
|
||||
}
|
||||
|
||||
function day(page, side, number) {
|
||||
return page.locator(`.drp-calendar.${side} td.available:not(.off)`)
|
||||
.filter({ hasText: new RegExp(`^${number}$`) });
|
||||
}
|
||||
|
||||
async function expectRange(page, start, end) {
|
||||
await expect(page.locator('[name="daterange"]')).toHaveValue(`${start} - ${end}`);
|
||||
await expect(page.locator('[name="startDate"]')).toHaveValue(start);
|
||||
await expect(page.locator('[name="endDate"]')).toHaveValue(end);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 공통 스크립트를 포함해 실행 중 JS 오류를 숨기지 않는다.
|
||||
page.on('pageerror', error => { throw error; });
|
||||
});
|
||||
|
||||
test('ships the patched release and rejects traversal before CommonJS require', () => {
|
||||
const requestedModules = [];
|
||||
const context = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
require(name) {
|
||||
requestedModules.push(name);
|
||||
throw new Error('Locale module is not installed in this isolated test');
|
||||
}
|
||||
};
|
||||
vm.runInNewContext(momentSource, context);
|
||||
const moment = context.module.exports;
|
||||
expect(moment.version).toBe('2.30.1');
|
||||
// 정상 로케일은 로딩을 시도해야 한다: require 분기가 실제 실행됨을 확인한다.
|
||||
moment.locale('fr');
|
||||
expect(requestedModules).toContain('./locale/fr');
|
||||
requestedModules.length = 0;
|
||||
moment.locale('../../package');
|
||||
moment.locale('..\\..\\package');
|
||||
expect(requestedModules).toEqual([]);
|
||||
expect(moment.locale()).toBe('en');
|
||||
});
|
||||
|
||||
test('initializes the real portal calendar with its date format and Korean labels', async ({ page }) => {
|
||||
await openCalendar(page);
|
||||
expect(await page.evaluate(() => window.moment.version)).toBe('2.30.1');
|
||||
await page.getByRole('button', { name: '달력 열기' }).click();
|
||||
await expect(page.locator('.daterangepicker')).toBeVisible();
|
||||
await expect(page.locator('.drp-calendar.left .month')).toHaveText('2024.02');
|
||||
await expect(page.locator('.drp-calendar.right .month')).toHaveText('2024.03');
|
||||
await expect(page.locator('.applyBtn')).toHaveText('확인');
|
||||
await expect(page.locator('.cancelBtn')).toHaveText('취소');
|
||||
});
|
||||
|
||||
test('selects leap day across months and updates the submitted fields', async ({ page }) => {
|
||||
await openCalendar(page);
|
||||
await page.locator('[name="daterange"]').click();
|
||||
await day(page, 'left', 29).click();
|
||||
await day(page, 'right', 3).click();
|
||||
await page.locator('.applyBtn').click();
|
||||
await expect(page.locator('.daterangepicker')).toBeHidden();
|
||||
await expectRange(page, '2024.02.29', '2024.03.03');
|
||||
});
|
||||
|
||||
test('navigates across year end and preserves the selected year', async ({ page }) => {
|
||||
await openCalendar(page, '2024.11.15', '2024.11.20');
|
||||
await page.locator('[name="daterange"]').click();
|
||||
await page.locator('.drp-calendar.right .next').click();
|
||||
await expect(page.locator('.drp-calendar.left .month')).toHaveText('2024.12');
|
||||
await expect(page.locator('.drp-calendar.right .month')).toHaveText('2025.01');
|
||||
await day(page, 'left', 31).click();
|
||||
await day(page, 'right', 2).click();
|
||||
await page.locator('.applyBtn').click();
|
||||
await expectRange(page, '2024.12.31', '2025.01.02');
|
||||
});
|
||||
|
||||
test('cancels a changed selection and restores the original dates', async ({ page }) => {
|
||||
await openCalendar(page);
|
||||
await page.locator('[name="daterange"]').click();
|
||||
await day(page, 'left', 29).click();
|
||||
await day(page, 'right', 4).click();
|
||||
await page.locator('.cancelBtn').click();
|
||||
await expectRange(page, '2024.02.28', '2024.03.02');
|
||||
await page.locator('[name="daterange"]').click();
|
||||
await expect(page.locator('.drp-calendar.left td.start-date')).toHaveText('28');
|
||||
await expect(page.locator('.drp-calendar.right td.end-date')).toHaveText('2');
|
||||
});
|
||||
|
||||
test('accepts a typed range across daylight saving and updates hidden fields', async ({ page }) => {
|
||||
await openCalendar(page);
|
||||
const input = page.locator('[name="daterange"]');
|
||||
await input.fill('2024.03.09 - 2024.03.11');
|
||||
// daterangepicker는 keyup에서 입력을 파싱하고 hide에서 제품 콜백을 호출한다.
|
||||
await input.press('ArrowRight');
|
||||
await input.press('Tab');
|
||||
await expectRange(page, '2024.03.09', '2024.03.11');
|
||||
});
|
||||
|
||||
test('supports same-day selection', async ({ page }) => {
|
||||
await openCalendar(page);
|
||||
await page.locator('[name="daterange"]').click();
|
||||
await day(page, 'left', 29).click();
|
||||
await day(page, 'left', 29).click();
|
||||
await page.locator('.applyBtn').click();
|
||||
await expectRange(page, '2024.02.29', '2024.02.29');
|
||||
});
|
||||
|
||||
test('validates leap dates and retains month-end arithmetic', async ({ page }) => {
|
||||
await openCalendar(page);
|
||||
const result = await page.evaluate(() => {
|
||||
const moment = window.moment;
|
||||
return {
|
||||
leap: moment('2024.02.29', 'YYYY.MM.DD', true).isValid(),
|
||||
invalid: moment('2023.02.29', 'YYYY.MM.DD', true).isValid(),
|
||||
malformed: moment('not-a-date', 'YYYY.MM.DD', true).isValid(),
|
||||
monthEnd: moment('2024.01.31', 'YYYY.MM.DD').add(1, 'month').format('YYYY.MM.DD'),
|
||||
dayAfter: moment('2024.02.29', 'YYYY.MM.DD').add(1, 'day').format('YYYY.MM.DD')
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({
|
||||
leap: true, invalid: false, malformed: false, monthEnd: '2024.02.29', dayAfter: '2024.03.01'
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: __dirname,
|
||||
testMatch: ['moment-compatibility.spec.js', 'statistics-date-range.spec.js'],
|
||||
outputDir: '../../../build/playwright',
|
||||
fullyParallel: true,
|
||||
workers: 2,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
locale: 'ko-KR',
|
||||
viewport: { width: 1280, height: 800 },
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{ name: 'seoul', use: { timezoneId: 'Asia/Seoul' } },
|
||||
{ name: 'new-york', use: { timezoneId: 'America/New_York' } }
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { readFileSync, existsSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const resources = path.resolve(__dirname, '../../main/resources');
|
||||
const staticRoot = path.join(resources, 'static');
|
||||
const template = readFileSync(path.join(resources, 'templates/views/apps/statistics/apiStatistics.html'), 'utf8');
|
||||
const summary = { totalCount: 123, successCount: 123, timeoutCount: 0, errorCount: 0,
|
||||
successRate: 100, timeoutRate: 0, errorRate: 0 };
|
||||
|
||||
function statisticsHtml() {
|
||||
// 실제 템플릿의 DOM/인라인 스크립트를 실행한다. 서버가 주입하는 데이터만 고정한다.
|
||||
const values = { details: [], periods: [], 'searchDto.clientId': null,
|
||||
statsMinDate: '2023-04-10', statsMaxDate: '2024-04-10' };
|
||||
for (const [key, value] of Object.entries(summary)) values[`summary.${key}`] = value;
|
||||
return template.replace(/\[\[\$\{\s*([^}]+?)\s*\}\]\]/g, (_, expression) => {
|
||||
if (!(expression in values)) throw new Error(`Missing server fixture value: ${expression}`);
|
||||
return JSON.stringify(values[expression]);
|
||||
}).replace('th:value="${#temporals.format(searchDto.startDate, \'yyyy-MM-dd\')}"', 'value="2024-02-28"')
|
||||
.replace('th:value="${#temporals.format(searchDto.endDate, \'yyyy-MM-dd\')}"', 'value="2024-03-02"')
|
||||
.replace('th:value="${m}"', 'value="202607"')
|
||||
.replace('th:value="${app.clientId}"', 'value="demo-app"')
|
||||
.replace('<body>', `<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="_csrf" content="test-csrf">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/daterangepicker.css">
|
||||
<script src="/plugins/jquery/jquery-3.7.1.min.js"></script>
|
||||
<script src="/js/moment.min.js"></script>
|
||||
<script src="/js/daterangepicker.js"></script>
|
||||
</head><body>`);
|
||||
}
|
||||
|
||||
async function openStatistics(page, storedParams) {
|
||||
const searches = [];
|
||||
page.on('pageerror', error => { throw error; });
|
||||
if (storedParams) {
|
||||
await page.addInitScript(params => sessionStorage.setItem('apiStatsParams', JSON.stringify(params)), storedParams);
|
||||
}
|
||||
await page.route('**/*', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === '/statistics/api/search') {
|
||||
searches.push({ payload: route.request().postDataJSON(), headers: route.request().headers() });
|
||||
return route.fulfill({ json: { summary, details: [], periods: [] } });
|
||||
}
|
||||
if (url.pathname === '/statistics/api') {
|
||||
return route.fulfill({ contentType: 'text/html', body: statisticsHtml() });
|
||||
}
|
||||
const asset = path.resolve(staticRoot, '.' + url.pathname);
|
||||
if (url.origin === 'http://portal.test' && asset.startsWith(staticRoot + path.sep) && existsSync(asset)) {
|
||||
return route.fulfill({ path: asset });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto('http://portal.test/statistics/api');
|
||||
await expect(page.locator('#statisticsDateRange')).not.toHaveValue('');
|
||||
return searches;
|
||||
}
|
||||
|
||||
function day(page, side, date) {
|
||||
return page.locator(`.statistics-date-picker .${side} td.available:not(.off)`)
|
||||
.filter({ hasText: new RegExp(`^${date}$`) });
|
||||
}
|
||||
|
||||
async function selectLeapRange(page) {
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await day(page, 'left', 29).click();
|
||||
await day(page, 'right', 3).click();
|
||||
}
|
||||
|
||||
test('statistics: initializes from server dates without a redundant search', async ({ page }, testInfo) => {
|
||||
const searches = await openStatistics(page);
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
await page.getByLabel('일별 조회 기간').press('Enter');
|
||||
await expect(page.locator('.statistics-date-picker')).toBeVisible();
|
||||
await expect(page.locator('.statistics-date-picker .left .month')).toHaveText('2월 2024');
|
||||
await expect(page.locator('.statistics-date-picker .applyBtn')).toHaveText('조회');
|
||||
expect(searches).toHaveLength(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('statistics-calendar.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('statistics: applies a leap-day range with one search and ISO payload', async ({ page }) => {
|
||||
const searches = await openStatistics(page);
|
||||
await selectLeapRange(page);
|
||||
expect(searches).toHaveLength(0);
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
expect(searches[0].payload).toMatchObject({ mode: 'DAILY', startDate: '2024-02-29', endDate: '2024-03-03' });
|
||||
expect(searches[0].headers['x-xsrf-token']).toBe('test-csrf');
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.29 - 2024.03.03');
|
||||
await expect(page.locator('#startDate')).toHaveValue('2024-02-29');
|
||||
await expect(page.locator('#endDate')).toHaveValue('2024-03-03');
|
||||
await expect(page.locator('#totalCount')).toHaveText('123');
|
||||
expect(await page.evaluate(() => JSON.parse(sessionStorage.getItem('apiStatsParams'))))
|
||||
.toMatchObject({ startDate: '2024-02-29', endDate: '2024-03-03' });
|
||||
});
|
||||
|
||||
for (const action of ['cancel', 'outside']) {
|
||||
test(`statistics: ${action} discards pending selection without searching`, async ({ page }) => {
|
||||
const searches = await openStatistics(page);
|
||||
await selectLeapRange(page);
|
||||
if (action === 'cancel') await page.locator('.statistics-date-picker .cancelBtn').click();
|
||||
else await page.locator('.statistics-notice').click();
|
||||
await expect(page.locator('.statistics-date-picker')).toBeHidden();
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
await expect(page.locator('#startDate')).toHaveValue('2024-02-28');
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await expect(page.locator('.statistics-date-picker .left .start-date')).toHaveText('28');
|
||||
await expect(page.locator('.statistics-date-picker .right .end-date')).toHaveText('2');
|
||||
expect(searches).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('statistics: restores saved dates and retains monthly mode and app selection', async ({ page }) => {
|
||||
const searches = await openStatistics(page, {
|
||||
mode: 'MONTHLY', startDate: '2024-03-09', endDate: '2024-03-11', month: '202607', clientId: 'demo-app'
|
||||
});
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await expect(page.locator('#dailyFilter')).toBeHidden();
|
||||
await expect(page.locator('#monthlyFilter')).toBeVisible();
|
||||
expect(searches[0].payload).toMatchObject({ mode: 'MONTHLY', month: '202607', clientId: 'demo-app' });
|
||||
await page.getByRole('button', { name: '일별', exact: true }).click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.03.09 - 2024.03.11');
|
||||
expect(searches[1].payload).toMatchObject({ mode: 'DAILY', startDate: '2024-03-09', endDate: '2024-03-11', clientId: 'demo-app' });
|
||||
await page.getByRole('button', { name: '통계 조회', exact: true }).click();
|
||||
await expect.poll(() => searches.length).toBe(3);
|
||||
expect(searches[2].payload).toEqual(searches[1].payload);
|
||||
});
|
||||
|
||||
test('statistics: limits selection to 40 inclusive days even across DST', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-03-01', endDate: '2024-03-02' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await day(page, 'left', 1).click();
|
||||
await expect(page.locator('.statistics-date-picker .right td.off.disabled:not(.ends)').filter({ hasText: /^10$/ })).toBeVisible();
|
||||
await day(page, 'right', 9).click();
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
expect(searches[1].payload).toMatchObject({ startDate: '2024-03-01', endDate: '2024-04-09' });
|
||||
});
|
||||
|
||||
test('statistics: disables dates before one year ago and allows the earliest day', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2023-04-10', endDate: '2023-04-10' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await expect(page.locator('.statistics-date-picker .left td.off.disabled:not(.ends)').filter({ hasText: /^9$/ })).toBeVisible();
|
||||
await day(page, 'left', 10).click();
|
||||
await day(page, 'left', 10).click();
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
expect(searches[1].payload).toMatchObject({ startDate: '2023-04-10', endDate: '2023-04-10' });
|
||||
});
|
||||
|
||||
test('statistics: allows server today and disables future dates regardless of browser timezone', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-03-20', endDate: '2024-04-10' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await expect(page.locator('.statistics-date-picker .right td.off.disabled:not(.ends)').filter({ hasText: /^11$/ })).toBeVisible();
|
||||
await day(page, 'right', 10).click();
|
||||
await page.locator('.statistics-date-picker td.start-date.available:not(.off)').click();
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
expect(searches[1].payload).toMatchObject({ startDate: '2024-04-10', endDate: '2024-04-10' });
|
||||
});
|
||||
|
||||
for (const [label, startDate, endDate] of [
|
||||
['before the earliest date', '2023-04-09', '2023-04-09'],
|
||||
['future date', '2024-04-11', '2024-04-11'],
|
||||
['41 inclusive days', '2024-03-01', '2024-04-10']
|
||||
]) {
|
||||
test(`statistics: discards cached ${label}`, async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate, endDate });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
expect(searches[0].payload).toMatchObject({ startDate: '2024-02-28', endDate: '2024-03-02' });
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
});
|
||||
|
||||
test(`statistics: blocks a forged ${label} before sending a request`, async ({ page }) => {
|
||||
const searches = await openStatistics(page);
|
||||
await page.evaluate(({ startDate, endDate }) => {
|
||||
document.querySelector('#startDate').value = startDate;
|
||||
document.querySelector('#endDate').value = endDate;
|
||||
}, { startDate, endDate });
|
||||
const messages = [];
|
||||
page.once('dialog', async dialog => {
|
||||
messages.push(dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '통계 조회', exact: true }).click();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toContain('조회');
|
||||
expect(searches).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('statistics: ignores invalid cached dates and keeps server defaults', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-02-30', endDate: '2024-05-01' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
expect(searches[0].payload).toMatchObject({ startDate: '2024-02-28', endDate: '2024-03-02' });
|
||||
});
|
||||
|
||||
test('statistics: calendar fits a mobile viewport and can apply a selection', async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const searches = await openStatistics(page);
|
||||
await selectLeapRange(page);
|
||||
const bounds = await page.locator('.statistics-date-picker').boundingBox();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: testInfo.outputPath('statistics-calendar-mobile.png'), fullPage: true });
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<h1 th:text="#{form.title}">
|
||||
</h1>
|
||||
<form th:action="@{/submit}" th:object="${form}" method="post">
|
||||
<input type="email" th:field="*{email}">
|
||||
<p th:errors="*{email}">
|
||||
</p>
|
||||
<select th:field="*{role}">
|
||||
<option value="USER">사용자</option>
|
||||
<option value="ADMIN">관리자</option>
|
||||
</select>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
<head>
|
||||
<title>Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>layout-shell</header>
|
||||
<main layout:fragment="content">default</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{compatibility/layout}">
|
||||
<body>
|
||||
<main layout:fragment="content">
|
||||
<b th:text="${name.toUpperCase()}">
|
||||
</b>
|
||||
<p th:text="${#temporals.format(date, 'yyyy.MM.dd')}">
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||
<body>
|
||||
<p sec:authorize="isAnonymous()">로그인 안내</p>
|
||||
<p sec:authorize="isAuthenticated()">회원 메뉴</p>
|
||||
<p sec:authorize="hasRole('ADMIN')">관리자 메뉴</p>
|
||||
<span id="identity" sec:authorize="isAuthenticated()" sec:authentication="name"></span>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* password-crypto.js 의 crypto.subtle 폴백용 forge 커스텀 번들 엔트리.
|
||||
*
|
||||
* 원격 오리진 HTTP(비 secure context)에서는 브라우저가 crypto.subtle 을 노출하지 않는다.
|
||||
* 그 환경에서도 같은 ENC1 봉투(RSA-OAEP SHA-256/MGF1-SHA-256 + AES-256-GCM)를 만들려고
|
||||
* node-forge 를 쓰는데, 배포판 forge.min.js 는 283KB 라 필요한 모듈만 묶는다.
|
||||
*
|
||||
* 빌드: npm run forge:build
|
||||
* → src/main/resources/static/js/lib/forge-crypto.min.js (약 111KB, gzip 31KB)
|
||||
*
|
||||
* 넣지 않은 것: x509/pem/pbe/pkcs12(인증서 파싱), hmac, des, ed25519 등. 봉투 생성에 안 쓴다.
|
||||
* pki.js 전체 대신 rsa.js 만 require 하는 이유도 같다 — publicKeyFromAsn1 은 rsa.js 에 있다.
|
||||
*/
|
||||
var forge = require('node-forge/lib/forge');
|
||||
|
||||
require('node-forge/lib/util'); // ByteBuffer, encodeUtf8
|
||||
require('node-forge/lib/asn1'); // SPKI DER 파싱
|
||||
require('node-forge/lib/sha256'); // OAEP 해시 · MGF1
|
||||
require('node-forge/lib/pkcs1'); // RSA-OAEP 패딩
|
||||
require('node-forge/lib/rsa'); // pki.publicKeyFromAsn1, publicKey.encrypt
|
||||
require('node-forge/lib/aes'); // cipher 'AES-GCM' 등록
|
||||
|
||||
module.exports = forge;
|
||||
Reference in New Issue
Block a user