Compare commits
20 Commits
feats/security
...
master
| 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
|
set -eu
|
||||||
cd build/libs
|
cd build/libs
|
||||||
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
sha256sum eapim-portal.war > eapim-portal.war.sha256
|
||||||
|
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||||
'''
|
'''
|
||||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
|
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||||
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
stash name: 'war', includes: 'build/libs/eapim-portal.war'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ pipeline {
|
|||||||
sha256sum "$f" > "$f.sha256"
|
sha256sum "$f" > "$f.sha256"
|
||||||
md5sum "$f" > "$f.md5"
|
md5sum "$f" > "$f.md5"
|
||||||
done
|
done
|
||||||
|
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
|
||||||
'''
|
'''
|
||||||
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
|
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-6
@@ -87,6 +87,10 @@ dependencies {
|
|||||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||||
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
||||||
}
|
}
|
||||||
|
// WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265).
|
||||||
|
// 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환.
|
||||||
|
// 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가).
|
||||||
|
implementation 'ognl:ognl:3.3.5'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation('org.springframework.boot:spring-boot-starter-cache')
|
implementation('org.springframework.boot:spring-boot-starter-cache')
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
@@ -190,13 +194,13 @@ ext {
|
|||||||
// 2.18.x 마지막 패치를 쓴다.
|
// 2.18.x 마지막 패치를 쓴다.
|
||||||
set('jackson-bom.version', '2.18.10')
|
set('jackson-bom.version', '2.18.10')
|
||||||
|
|
||||||
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
|
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
|
||||||
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
|
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
|
||||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
|
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
|
||||||
// org.thymeleaf.spring5 + javax.servlet 그대로다.
|
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
|
||||||
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
|
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
|
||||||
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
||||||
set('thymeleaf.version', '3.1.4.RELEASE')
|
set('thymeleaf.version', '3.1.5.RELEASE')
|
||||||
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
|
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
|
||||||
|
|
||||||
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||||
@@ -315,6 +319,18 @@ war {
|
|||||||
classpath = excludeLocalOnlyLibs(classpath)
|
classpath = excludeLocalOnlyLibs(classpath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 정적 리소스만 별도 zip (OHS 추가 배포용). war/bootWar 는 그대로 정적 리소스 포함 유지
|
||||||
|
// (WAS 단독 접속 인원 지원). sass/ 는 컴파일 소스이며 실제 서빙 경로에 없어 제외
|
||||||
|
// (PortalConfigWebDispatcherServlet#addResourceHandlers 기준).
|
||||||
|
task staticResourcesZip(type: Zip) {
|
||||||
|
archiveFileName = "eapim-portal-static.zip"
|
||||||
|
destinationDirectory = file("$buildDir/libs")
|
||||||
|
from('src/main/resources/static') {
|
||||||
|
exclude 'sass/**'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assemble.dependsOn staticResourcesZip
|
||||||
|
|
||||||
task printSourceSets {
|
task printSourceSets {
|
||||||
doLast {
|
doLast {
|
||||||
sourceSets.each { srcSet ->
|
sourceSets.each { srcSet ->
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
## OHS Static Resource 설정 예시
|
||||||
|
|
||||||
|
```apacheconf
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName weblogic-djb.rinjae.kr
|
||||||
|
|
||||||
|
<IfModule ossl_module>
|
||||||
|
SSLEngine on
|
||||||
|
SSLVerifyClient None
|
||||||
|
SSLProtocol TLSv1.2 TLSv1.3
|
||||||
|
SSLHonorCipherOrder on
|
||||||
|
SSLCipherSuite TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||||
|
SSLWallet "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/keystores/default"
|
||||||
|
|
||||||
|
<FilesMatch "\.(cgi|shtml|phtml|php)$">
|
||||||
|
SSLOptions +StdEnvVars
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<Directory "${ORACLE_INSTANCE}/config/fmwconfig/components/${COMPONENT_TYPE}/instances/${COMPONENT_NAME}/cgi-bin">
|
||||||
|
SSLOptions +StdEnvVars
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
BrowserMatch "MSIE [2-5]" \
|
||||||
|
nokeepalive ssl-unclean-shutdown \
|
||||||
|
downgrade-1.0 force-response-1.0
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header always set Strict-Transport-Security "max-age=63072000; preload; includeSubDomains"
|
||||||
|
</IfModule>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
Alias /css /apps/portal-static/css
|
||||||
|
Alias /js /apps/portal-static/js
|
||||||
|
Alias /img /apps/portal-static/img
|
||||||
|
Alias /images /apps/portal-static/images
|
||||||
|
Alias /webfonts /apps/portal-static/webfonts
|
||||||
|
Alias /font /apps/portal-static/font
|
||||||
|
Alias /html /apps/portal-static/html
|
||||||
|
Alias /plugins /apps/portal-static/plugins
|
||||||
|
Alias /favicon.png /apps/portal-static/favicon.png
|
||||||
|
|
||||||
|
<Directory "/apps/portal-static">
|
||||||
|
Require all granted
|
||||||
|
Options -Indexes
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<LocationMatch "^/favicon\.png$|^/(css|js|img|images|webfonts|font|html|plugins)/">
|
||||||
|
Header set Cache-Control "no-cache"
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<IfModule weblogic_module>
|
||||||
|
<Location />
|
||||||
|
DirectoryIndex disabled
|
||||||
|
SetHandler weblogic-handler
|
||||||
|
WebLogicHost 172.30.1.100
|
||||||
|
WebLogicPort 39130
|
||||||
|
DynamicServerList OFF
|
||||||
|
ConnectTimeoutSecs 10
|
||||||
|
ConnectRetrySecs 2
|
||||||
|
WLProxySSL ON
|
||||||
|
</Location>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<Location /css>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /js>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /img>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /images>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /webfonts>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /font>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /html>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /plugins>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
<Location /favicon.png>
|
||||||
|
SetHandler None
|
||||||
|
</Location>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
import http.cookiejar
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = 'https://api.jejubank.co.kr'
|
||||||
|
COOKIE_NAME = 'JSESSIONID_PORTAL'
|
||||||
|
USER_AGENT = (
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||||
|
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||||
|
'Chrome/140.0.0.0 Safari/537.36'
|
||||||
|
)
|
||||||
|
COLUMNS = (
|
||||||
|
('회', 3),
|
||||||
|
('대상', 4),
|
||||||
|
('결과', 10),
|
||||||
|
('쿠키', 8),
|
||||||
|
('CSRF', 8),
|
||||||
|
('HTTP', 4),
|
||||||
|
('요청 값', 10),
|
||||||
|
('요청 서버ID', 11),
|
||||||
|
('응답 값', 10),
|
||||||
|
('응답 서버ID', 11),
|
||||||
|
)
|
||||||
|
BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+'
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, request, fp, code, message, headers, new_url):
|
||||||
|
# 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다.
|
||||||
|
# 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def integer_between(minimum, maximum):
|
||||||
|
def parse(value):
|
||||||
|
try:
|
||||||
|
number = int(value)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||||
|
)
|
||||||
|
if not minimum <= number <= maximum:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f'{minimum}~{maximum} 사이의 정수를 입력하세요.'
|
||||||
|
)
|
||||||
|
return number
|
||||||
|
return parse
|
||||||
|
|
||||||
|
|
||||||
|
def host_address(value):
|
||||||
|
value = value.strip()
|
||||||
|
message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.'
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlsplit(value)
|
||||||
|
parsed.port # 포트 형식과 범위도 검사한다.
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(message)
|
||||||
|
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
|
||||||
|
or parsed.path not in ('', '/') or parsed.query or parsed.fragment
|
||||||
|
or parsed.username is not None or parsed.password is not None
|
||||||
|
or any(char.isspace() for char in value)):
|
||||||
|
raise argparse.ArgumentTypeError(message)
|
||||||
|
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--host', type=host_address, default=DEFAULT_HOST,
|
||||||
|
metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--host2', type=host_address, metavar='주소',
|
||||||
|
help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--cookie-mode', choices=('shared', 'separate'), default='shared',
|
||||||
|
help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-n', '--count', type=integer_between(1, 100), default=10,
|
||||||
|
metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50,
|
||||||
|
metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)',
|
||||||
|
)
|
||||||
|
tls_options = parser.add_mutually_exclusive_group()
|
||||||
|
tls_options.add_argument(
|
||||||
|
'--cacert', metavar='CA파일',
|
||||||
|
help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)',
|
||||||
|
)
|
||||||
|
tls_options.add_argument(
|
||||||
|
'-k', '--insecure', action='store_true',
|
||||||
|
help='HTTPS 서버 인증서와 호스트명 검증 생략',
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def tls_context(args):
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
if args.cacert:
|
||||||
|
context.load_verify_locations(cafile=args.cacert)
|
||||||
|
if args.insecure:
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_from_headers(headers):
|
||||||
|
value = None
|
||||||
|
for header in headers:
|
||||||
|
parsed = SimpleCookie()
|
||||||
|
parsed.load(header)
|
||||||
|
if COOKIE_NAME in parsed:
|
||||||
|
value = parsed[COOKIE_NAME].value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_for_url(jar, url):
|
||||||
|
request = urllib.request.Request(url)
|
||||||
|
jar.add_cookie_header(request)
|
||||||
|
return cookie_from_headers([request.get_header('Cookie', '')])
|
||||||
|
|
||||||
|
|
||||||
|
def host_key(url):
|
||||||
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80)
|
||||||
|
return parsed.scheme, parsed.hostname, port
|
||||||
|
|
||||||
|
|
||||||
|
def carry_session_cookie(request, value):
|
||||||
|
# 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다.
|
||||||
|
cookies = SimpleCookie()
|
||||||
|
cookies.load(request.get_header('Cookie', ''))
|
||||||
|
cookies[COOKIE_NAME] = value
|
||||||
|
request.add_unredirected_header(
|
||||||
|
'Cookie', cookies.output(header='', sep='; ').strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def server_id(cookie):
|
||||||
|
return cookie.split('!')[1] if cookie and '!' in cookie else '-'
|
||||||
|
|
||||||
|
|
||||||
|
def cell(value, width):
|
||||||
|
text = str(value)
|
||||||
|
# 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다.
|
||||||
|
display_width = sum(
|
||||||
|
0 if unicodedata.combining(char) else
|
||||||
|
2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1
|
||||||
|
for char in text
|
||||||
|
)
|
||||||
|
return text + ' ' * max(0, width - display_width)
|
||||||
|
|
||||||
|
|
||||||
|
def print_row(values):
|
||||||
|
print('| ' + ' | '.join(
|
||||||
|
cell(value, width) for value, (_, width) in zip(values, COLUMNS)
|
||||||
|
) + ' |', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def change_state(previous, current):
|
||||||
|
if previous is None:
|
||||||
|
return '최초'
|
||||||
|
return '유지' if previous == current else '!!변경!!'
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe(args):
|
||||||
|
targets = [('A', args.host + '/api/session/csrf')]
|
||||||
|
if args.host2:
|
||||||
|
targets.append(('B', args.host2 + '/api/session/csrf'))
|
||||||
|
try:
|
||||||
|
context = tls_context(args)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
print(f'TLS 설정 실패: {error}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
cookie_processor = urllib.request.HTTPCookieProcessor(jar)
|
||||||
|
handlers = [
|
||||||
|
urllib.request.HTTPSHandler(context=context),
|
||||||
|
cookie_processor,
|
||||||
|
]
|
||||||
|
if args.host2:
|
||||||
|
handlers.append(NoRedirectHandler())
|
||||||
|
client = urllib.request.build_opener(*handlers)
|
||||||
|
previous_cookie = None
|
||||||
|
previous_token = None
|
||||||
|
cookie_stores = {}
|
||||||
|
session_states = {}
|
||||||
|
totals = {'최초': 0, '유지': 0, '변경': 0}
|
||||||
|
completed = 0
|
||||||
|
failure = None
|
||||||
|
exit_code = 0
|
||||||
|
|
||||||
|
for target, url in targets:
|
||||||
|
print(f'대상 {target}: {url}')
|
||||||
|
if len(targets) == 2:
|
||||||
|
print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)')
|
||||||
|
print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.')
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리')
|
||||||
|
elif len(targets) == 2:
|
||||||
|
print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달')
|
||||||
|
if any(url.startswith('https://') for _, url in targets):
|
||||||
|
tls_mode = ('생략 (--insecure)' if args.insecure else
|
||||||
|
f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용')
|
||||||
|
print(f'TLS 인증서 검증: {tls_mode}')
|
||||||
|
print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms')
|
||||||
|
print(BORDER)
|
||||||
|
print_row([title for title, _ in COLUMNS])
|
||||||
|
print(BORDER, flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for number in range(1, args.count + 1):
|
||||||
|
if number > 1 and args.delay:
|
||||||
|
time.sleep(args.delay / 1000)
|
||||||
|
|
||||||
|
target, url = targets[(number - 1) % len(targets)]
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
key = host_key(url)
|
||||||
|
if key not in cookie_stores:
|
||||||
|
cookie_stores[key] = http.cookiejar.CookieJar()
|
||||||
|
jar = cookie_stores[key]
|
||||||
|
# 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다.
|
||||||
|
cookie_processor.cookiejar = jar
|
||||||
|
previous_cookie, previous_token = session_states.get(key, (None, None))
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다.
|
||||||
|
jar.add_cookie_header(request)
|
||||||
|
if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None:
|
||||||
|
carry_session_cookie(request, previous_cookie)
|
||||||
|
request_cookie = cookie_from_headers([request.get_header('Cookie', '')])
|
||||||
|
response_cookie = None
|
||||||
|
status = '-'
|
||||||
|
try:
|
||||||
|
with client.open(request, timeout=10) as response:
|
||||||
|
status = response.status
|
||||||
|
response_url = response.geturl()
|
||||||
|
# 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'.
|
||||||
|
response_cookie = cookie_from_headers(
|
||||||
|
response.headers.get_all('Set-Cookie') or []
|
||||||
|
)
|
||||||
|
data = json.load(response)
|
||||||
|
|
||||||
|
if args.host2 and args.cookie_mode == 'shared' and response_cookie is None:
|
||||||
|
# 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다.
|
||||||
|
# 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다.
|
||||||
|
cookie = request_cookie
|
||||||
|
else:
|
||||||
|
cookie = cookie_for_url(jar, response_url)
|
||||||
|
token = data.get('token') if isinstance(data, dict) else None
|
||||||
|
if not cookie or not token:
|
||||||
|
raise ValueError('세션 쿠키 또는 CSRF 토큰 없음')
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
status = error.code
|
||||||
|
response_cookie = cookie_from_headers(
|
||||||
|
error.headers.get_all('Set-Cookie') or []
|
||||||
|
)
|
||||||
|
failure = f'{number}회: HTTP {status}'
|
||||||
|
error.close()
|
||||||
|
except Exception as error:
|
||||||
|
failure = f'{number}회: {error}'
|
||||||
|
|
||||||
|
if failure:
|
||||||
|
result, cookie_state, token_state = '!!실패!!', '-', '-'
|
||||||
|
exit_code = 1
|
||||||
|
else:
|
||||||
|
# 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다.
|
||||||
|
cookie_state = change_state(previous_cookie, cookie)
|
||||||
|
token_state = change_state(previous_token, token)
|
||||||
|
if '!!변경!!' in (cookie_state, token_state):
|
||||||
|
outcome, result = '변경', '>>>변경<<<'
|
||||||
|
elif previous_cookie is None:
|
||||||
|
outcome, result = '최초', '[최초]'
|
||||||
|
else:
|
||||||
|
outcome, result = '유지', '[유지]'
|
||||||
|
totals[outcome] += 1
|
||||||
|
completed += 1
|
||||||
|
previous_cookie, previous_token = cookie, token
|
||||||
|
if args.cookie_mode == 'separate':
|
||||||
|
session_states[host_key(url)] = (cookie, token)
|
||||||
|
|
||||||
|
print_row((
|
||||||
|
number, target, result, cookie_state, token_state, status,
|
||||||
|
request_cookie[:10] if request_cookie else '없음',
|
||||||
|
server_id(request_cookie),
|
||||||
|
response_cookie[:10] if response_cookie else '없음',
|
||||||
|
server_id(response_cookie),
|
||||||
|
))
|
||||||
|
if failure:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
failure = '사용자가 중단했습니다.'
|
||||||
|
exit_code = 130
|
||||||
|
|
||||||
|
print(BORDER)
|
||||||
|
print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}회')
|
||||||
|
print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.')
|
||||||
|
if failure:
|
||||||
|
print(f'실패/중단: {failure}')
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = parse_args(argv)
|
||||||
|
started_counter = time.perf_counter()
|
||||||
|
started_at = datetime.now().astimezone()
|
||||||
|
print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True)
|
||||||
|
try:
|
||||||
|
return run_probe(args)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('실패/중단: 사용자가 중단했습니다.')
|
||||||
|
return 130
|
||||||
|
finally:
|
||||||
|
finished_at = datetime.now().astimezone()
|
||||||
|
# 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다.
|
||||||
|
elapsed = time.perf_counter() - started_counter
|
||||||
|
print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}')
|
||||||
|
print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
|
||||||
|
// Read-only dependency resolution; does not override versions or the normal build.
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
def portal = gradle.rootProject
|
||||||
|
portal.tasks.register('thymeleafCompatibilityDependencies') {
|
||||||
|
doLast {
|
||||||
|
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
|
||||||
|
output.mkdirs()
|
||||||
|
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
|
||||||
|
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
|
||||||
|
def rows = artifacts.findAll {
|
||||||
|
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||||
|
}.collect {
|
||||||
|
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
|
||||||
|
}.sort()
|
||||||
|
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
println "Dependency evidence: ${output}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_CHANGES = {
|
||||||
|
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"ognl:ognl": ("3.3.4", "3.3.5"),
|
||||||
|
}
|
||||||
|
EXPECTED_CLASSES = {
|
||||||
|
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
|
||||||
|
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
|
||||||
|
"ognl/Ognl.class": "ognl-3.3.5.jar",
|
||||||
|
}
|
||||||
|
LOCAL_PREFIXES = (
|
||||||
|
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
|
||||||
|
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies(path):
|
||||||
|
result = {}
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
module, version, filename = line.split("\t")
|
||||||
|
entries = result.setdefault(module, [])
|
||||||
|
require((version, filename) not in entries, "Duplicate artifact: " + filename)
|
||||||
|
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
|
||||||
|
entries.append((version, filename))
|
||||||
|
for entries in result.values():
|
||||||
|
entries.sort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compare(before, after):
|
||||||
|
old, new = dependencies(before), dependencies(after)
|
||||||
|
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
|
||||||
|
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
|
||||||
|
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
|
||||||
|
return {"external_modules": len(new), "changes": changed}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_war(path):
|
||||||
|
owners = {name: [] for name in EXPECTED_CLASSES}
|
||||||
|
with zipfile.ZipFile(path) as war:
|
||||||
|
names = war.namelist()
|
||||||
|
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
|
||||||
|
jars = sorted(name for name in names if name.endswith(".jar"))
|
||||||
|
basenames = [Path(name).name for name in jars]
|
||||||
|
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
|
||||||
|
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
|
||||||
|
for name in names), "Local profile packaged: " + str(path))
|
||||||
|
for name in jars:
|
||||||
|
basename = Path(name).name
|
||||||
|
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
|
||||||
|
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
|
||||||
|
classes = set(jar.namelist())
|
||||||
|
require(not any(c.startswith("jakarta/servlet/") for c in classes),
|
||||||
|
"Jakarta Servlet classes packaged: " + name)
|
||||||
|
for target in owners:
|
||||||
|
if target in classes:
|
||||||
|
owners[target].append(name)
|
||||||
|
# Java 8 compatibility of each upgraded library's entry class.
|
||||||
|
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
|
||||||
|
"Java >8 class: " + name + "!" + target)
|
||||||
|
for target, expected in EXPECTED_CLASSES.items():
|
||||||
|
require(owners[target] == ["WEB-INF/lib/" + expected],
|
||||||
|
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
|
||||||
|
for pattern, expected in [
|
||||||
|
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
|
||||||
|
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
|
||||||
|
(r"ognl-", "ognl-3.3.5.jar"),
|
||||||
|
]:
|
||||||
|
require([n for n in basenames if re.match(pattern, n)] == [expected],
|
||||||
|
"Wrong/duplicate library version for " + expected)
|
||||||
|
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"jar_count": len(jars), "class_providers": owners, "jars": jars}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
|
||||||
|
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
|
||||||
|
parser.add_argument("--war", type=Path, action="append", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
|
||||||
|
result = {
|
||||||
|
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
|
||||||
|
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
|
||||||
|
"wars": [inspect_war(path) for path in args.war],
|
||||||
|
}
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
print("PASS: dependency changes limited to three modules; both WARs verified")
|
||||||
|
for war in result["wars"]:
|
||||||
|
print(war["file"], war["sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Generated
+542
@@ -9,9 +9,454 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
@@ -322,6 +767,22 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -349,6 +810,48 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
@@ -389,6 +892,16 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/node-forge": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
@@ -403,6 +916,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
|||||||
+7
-2
@@ -1,15 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "eapim-portal",
|
"name": "eapim-portal",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "SASS build system for EAPIM Portal",
|
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
|
||||||
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||||
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
||||||
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||||
"build": "npm run sass:build && npm run sass:build:minified",
|
"build": "npm run sass:build && npm run sass:build:minified",
|
||||||
"dev": "npm run sass:watch"
|
"dev": "npm run sass:watch",
|
||||||
|
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
|
|||||||
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||||
|
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
|
||||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
@@ -17,6 +18,7 @@ import java.util.Optional;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@@ -37,6 +39,7 @@ public class ApiController {
|
|||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
private final ApiSearchFacade apiSearchFacade;
|
private final ApiSearchFacade apiSearchFacade;
|
||||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||||
|
private final ApiListProperties apiListProperties;
|
||||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||||
|
|
||||||
@@ -88,7 +91,8 @@ public class ApiController {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
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("search", search);
|
||||||
model.addAttribute("services", searchResult.get("services"));
|
model.addAttribute("services", searchResult.get("services"));
|
||||||
@@ -109,8 +113,8 @@ public class ApiController {
|
|||||||
*/
|
*/
|
||||||
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||||
int total = apis == null ? 0 : apis.size();
|
int total = apis == null ? 0 : apis.size();
|
||||||
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
|
int fromIndex = (int) Math.min(pageable.getOffset(), total);
|
||||||
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
|
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
|
||||||
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
||||||
|
|
||||||
return new PageImpl<>(content, pageable, total);
|
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;
|
package com.eactive.apim.portal.apps.app.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||||
*
|
*
|
||||||
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
|
||||||
|
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
|
||||||
|
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
|
||||||
|
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
|
|||||||
private static final String PROP_GROUP = "Portal";
|
private static final String PROP_GROUP = "Portal";
|
||||||
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||||
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||||
|
private static final String PROP_ADMIN_BASE_URL_DESC =
|
||||||
|
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
|
||||||
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
|
|||||||
/**
|
/**
|
||||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||||
*
|
*
|
||||||
|
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
|
||||||
|
*
|
||||||
* @param clientId 차단할 클라이언트 ID
|
* @param clientId 차단할 클라이언트 ID
|
||||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||||
*/
|
*/
|
||||||
public void blockClient(String clientId) {
|
public void blockClient(String clientId) {
|
||||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
List<String> baseUrls = resolveBaseUrls();
|
||||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
|
||||||
|
|
||||||
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
RestClientException lastError = null;
|
||||||
|
for (int i = 0; i < baseUrls.size(); i++) {
|
||||||
|
String baseUrl = baseUrls.get(i);
|
||||||
|
String url = baseUrl + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
ResponseEntity<Map> response;
|
||||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
try {
|
||||||
|
response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
// 통신 계층 실패 — 다음 admin 주소로 failover
|
||||||
|
lastError = e;
|
||||||
|
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
|
||||||
|
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
Map<?, ?> body = response.getBody();
|
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
|
||||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
Map<?, ?> body = response.getBody();
|
||||||
if (!success) {
|
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
if (!success) {
|
||||||
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
|
||||||
|
*
|
||||||
|
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
|
||||||
|
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
|
||||||
|
*/
|
||||||
|
private List<String> resolveBaseUrls() {
|
||||||
|
String raw = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
|
||||||
|
|
||||||
|
List<String> urls = parseBaseUrls(raw);
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
|
||||||
|
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
|
||||||
|
*/
|
||||||
|
static List<String> parseBaseUrls(String raw) {
|
||||||
|
Set<String> ordered = new LinkedHashSet<>();
|
||||||
|
if (raw != null) {
|
||||||
|
for (String token : raw.split("[,\\r\\n]")) {
|
||||||
|
String url = stripTrailingSlashes(token.trim());
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
ordered.add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+8
-3
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||||
|
|
||||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
searchDto.setStartDate(today.minusDays(7));
|
||||||
searchDto.setEndDate(LocalDate.now());
|
searchDto.setEndDate(today);
|
||||||
searchDto.setClientId(null);
|
searchDto.setClientId(null);
|
||||||
|
|
||||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("details", result.getDetails());
|
model.addAttribute("details", result.getDetails());
|
||||||
model.addAttribute("periods", result.getPeriods());
|
model.addAttribute("periods", result.getPeriods());
|
||||||
model.addAttribute("searchDto", searchDto);
|
model.addAttribute("searchDto", searchDto);
|
||||||
|
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||||
|
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||||
|
model.addAttribute("statsMaxDate", today.toString());
|
||||||
|
|
||||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
|||||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||||
return searchDto.isMonthly()
|
return searchDto.isMonthly()
|
||||||
? "조회할 월이 올바르지 않습니다."
|
? "조회할 월이 올바르지 않습니다."
|
||||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||||
|
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private PortalOrg getPortalOrg() {
|
private PortalOrg getPortalOrg() {
|
||||||
|
|||||||
+9
-4
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
|||||||
public class ApiStatisticsSearchDto {
|
public class ApiStatisticsSearchDto {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최대 조회 가능 일수 (일별 모드).
|
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||||
*/
|
*/
|
||||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||||
|
|
||||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||||
*/
|
*/
|
||||||
public boolean isValidDateRange() {
|
public boolean isValidDateRange() {
|
||||||
|
return isValidDateRange(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isValidDateRange(LocalDate today) {
|
||||||
if (startDate == null || endDate == null) {
|
if (startDate == null || endDate == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (startDate.isAfter(endDate)) {
|
if (startDate.isAfter(endDate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
LocalDate earliestDate = today.minusYears(1);
|
||||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
|
||||||
|
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
|||||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
@@ -22,21 +21,15 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -64,7 +57,6 @@ public class UserRegisterController {
|
|||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
private final PortalProperties portalProperties;
|
private final PortalProperties portalProperties;
|
||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final PortalUserAuthService portalUserAuthService;
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
|||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.common.exception.SystemException;
|
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||||
import com.eactive.apim.portal.file.service.FileService;
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||||
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
|
|
||||||
approvalService.createUserApproval(newUser);
|
approvalService.createUserApproval(newUser);
|
||||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendActivationEmail(newUser);
|
|
||||||
|
|
||||||
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendActivationEmail(PortalUser newUser) {
|
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
|
||||||
recipient.setUsername(newUser.getUserName());
|
|
||||||
recipient.setUserId(newUser.getEmailAddr());
|
|
||||||
recipient.setPhone(newUser.getMobileNumber());
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
|
|
||||||
|
|
||||||
try {
|
|
||||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
|
||||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
|
||||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
throw new SystemException("암호화 모듈 오류");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
||||||
private ValidationResponse convertExistingUserToCorporate(
|
private ValidationResponse convertExistingUserToCorporate(
|
||||||
PortalUser existingUser,
|
PortalUser existingUser,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
||||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
|
|
||||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||||
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
|||||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
|
|||||||
|
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
private final PasswordValidator passwordValidator;
|
private final PasswordValidator passwordValidator;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AuthNumberGenerator authNumberGenerator;
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendEmailActivation(newUser);
|
|
||||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendEmailActivation(PortalUser registeredUser) {
|
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
|
||||||
recipient.setUsername(registeredUser.getUserName());
|
|
||||||
recipient.setUserId(registeredUser.getEmailAddr());
|
|
||||||
recipient.setPhone(registeredUser.getMobileNumber());
|
|
||||||
|
|
||||||
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
|
|
||||||
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
|
|
||||||
params.put("token", tokenValue);
|
|
||||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// String encToken = encryptionUtil.encrypt(tokenValue);
|
|
||||||
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
|
|
||||||
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
// throw new SystemException("암호화 모듈 오류");
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
||||||
|
|||||||
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final MessageHandlerService messageHandlerService;
|
private final MessageHandlerService messageHandlerService;
|
||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final LoginFinalizer loginFinalizer;
|
private final LoginFinalizer loginFinalizer;
|
||||||
private final PasswordService passwordService;
|
private final PasswordService passwordService;
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -130,13 +130,22 @@ public class GlobalControllerAdvice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비밀번호 전송암호화 사용 여부(yml portal.security.password-encrypt.enabled).
|
* 비밀번호 전송암호화 설정(yml {@code portal.security.password-encrypt.*}).
|
||||||
* head 의 window.__PASSWORD_CRYPTO__ 로 내려가 password-crypto.js 가 읽는다.
|
* head 의 {@code window.__PASSWORD_CRYPTO__} 로 내려가 password-crypto.js 가 읽는다.
|
||||||
* 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 화면은 평문으로 폴백한다.
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code enabled} - 꺼져 있으면 화면은 아무것도 하지 않고 평문 전송한다.</li>
|
||||||
|
* <li>{@code policy} - 평문 비밀번호 처리 정책. 화면은 경고 팝업 노출 여부·문구를 여기서 정한다.</li>
|
||||||
|
* <li>{@code softwareFallback} - {@code crypto.subtle} 이 없을 때 forge 번들을 내려받아 쓸지.</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@ModelAttribute("passwordCryptoEnabled")
|
@ModelAttribute("passwordCrypto")
|
||||||
public boolean passwordCryptoEnabled() {
|
public Map<String, Object> passwordCrypto() {
|
||||||
return passwordCryptoProperties.isEnabled();
|
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 ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
|
||||||
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
|
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
|
||||||
*
|
*
|
||||||
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 1})보다 <b>앞</b>에서 이 래퍼가 씌워지므로,
|
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 3})보다 <b>앞</b>({@code MIN_VALUE + 2})에서 이 래퍼가
|
||||||
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 비밀번호의
|
* 씌워지므로, 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든
|
||||||
* 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
|
* 비밀번호의 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</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
|
@Slf4j
|
||||||
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
||||||
@@ -37,6 +45,13 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
|||||||
|
|
||||||
private Map<String, String[]> parameterMapCache;
|
private Map<String, String[]> parameterMapCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
|
||||||
|
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
|
||||||
|
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
|
||||||
|
*/
|
||||||
|
private boolean plaintextWarned;
|
||||||
|
|
||||||
public DecryptingRequestWrapper(HttpServletRequest request,
|
public DecryptingRequestWrapper(HttpServletRequest request,
|
||||||
PasswordEnvelopeCodec codec,
|
PasswordEnvelopeCodec codec,
|
||||||
PasswordKeyStore keyStore,
|
PasswordKeyStore keyStore,
|
||||||
@@ -97,13 +112,7 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (!codec.isEnvelope(value)) {
|
if (!codec.isEnvelope(value)) {
|
||||||
// strict 모드에서는 평문 비밀번호를 받아들이지 않는다. 예외를 던지면 로그인 경로가 500 이 되므로
|
return handlePlaintext(name, value);
|
||||||
// 빈 값으로 바꿔 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.
|
|
||||||
if (properties.isStrict() && PasswordParamNames.isPasswordLike(name)) {
|
|
||||||
log.warn("암호화되지 않은 비밀번호 파라미터 거부(strict) - uri={}, param={}", getRequestURI(), name);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String cached = decrypted.get(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.*}
|
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
|
||||||
*
|
*
|
||||||
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.
|
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.</p>
|
||||||
* 로컬 개발환경은 HTTP(비 secure context)라 브라우저 {@code crypto.subtle} 을 쓸 수 없으므로
|
|
||||||
* {@link #enabled} 를 꺼둔다. 켜져 있어도 브라우저가 지원하지 못하면 클라이언트가 평문으로 폴백한다.</p>
|
|
||||||
*
|
*
|
||||||
* <p><b>이 기능은 XSS 방어가 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있다.
|
* <p><b>이 기능은 XSS 방어도, MITM 방어도 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있고,
|
||||||
* "전송 페이로드 평문 노출" 점검 지적에 대한 대응 범위로만 이해할 것.</p>
|
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. "전송 페이로드 평문 노출" 점검 지적에
|
||||||
|
* 대한 대응 범위로만 이해할 것.</p>
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Component
|
@Component
|
||||||
@@ -32,6 +31,24 @@ public class PasswordCryptoProperties {
|
|||||||
REQUEST, SESSION, SERVER
|
REQUEST, SESSION, SERVER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봉투(ENC1)가 적용되지 않은 <b>평문 비밀번호 파라미터</b>를 서버가 어떻게 다룰지.
|
||||||
|
* 전송 구간(HTTP/HTTPS) 자체를 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code NONE} - 무동작. 평문을 그대로 받는다.</li>
|
||||||
|
* <li>{@code PERMISSIVE} - 평문을 받되 서버 로그에 경고를 남기고, 화면에도 경고 팝업을 띄운다.</li>
|
||||||
|
* <li>{@code ENFORCE} - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>{@code ENFORCE} 는 전송 구간이 HTTP 여도 안전하게 켤 수 있다. {@link #softwareFallback} 이 켜져 있으면
|
||||||
|
* {@code crypto.subtle} 을 못 쓰는 환경에서도 클라이언트가 봉투를 만들기 때문이다. 다만 JS 를 끈
|
||||||
|
* 브라우저는 로그인하지 못하므로 {@code PERMISSIVE} 로 운영해 경고 로그를 지켜본 뒤 승격한다.</p>
|
||||||
|
*/
|
||||||
|
public enum PlaintextPolicy {
|
||||||
|
NONE, PERMISSIVE, ENFORCE
|
||||||
|
}
|
||||||
|
|
||||||
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
|
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
|
||||||
private boolean enabled = false;
|
private boolean enabled = false;
|
||||||
|
|
||||||
@@ -41,11 +58,18 @@ public class PasswordCryptoProperties {
|
|||||||
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
|
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
|
||||||
private int keyTtlSeconds = 300;
|
private int keyTtlSeconds = 300;
|
||||||
|
|
||||||
|
/** 평문 비밀번호 파라미터 처리 정책. */
|
||||||
|
private PlaintextPolicy plaintextPolicy = PlaintextPolicy.NONE;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* true 면 서버가 평문 비밀번호 파라미터를 거부한다(빈 값으로 치환 → 인증 실패).
|
* {@code crypto.subtle} 을 쓸 수 없는 환경(원격 오리진 HTTP = 비 secure context)에서
|
||||||
* JS 비활성 사용자나 구형 브라우저가 로그인하지 못하게 되므로 안정화 후에만 켠다.
|
* 순수 JS 구현(forge)으로 봉투를 만들지 여부.
|
||||||
|
*
|
||||||
|
* <p>forge 번들은 {@code crypto.subtle} 이 없을 때만 동적으로 내려가므로 HTTPS 운영 환경에서는
|
||||||
|
* 전송 바이트가 0 이다. 즉 이 값은 사실상 킬스위치다. 끄면 HTTP 개발환경은 평문으로 폴백한다
|
||||||
|
* ({@link #plaintextPolicy} 가 {@code ENFORCE} 면 로그인 불가).</p>
|
||||||
*/
|
*/
|
||||||
private boolean strict = false;
|
private boolean softwareFallback = true;
|
||||||
|
|
||||||
/** RSA 키 길이. */
|
/** RSA 키 길이. */
|
||||||
private int rsaKeySize = 2048;
|
private int rsaKeySize = 2048;
|
||||||
|
|||||||
+26
-6
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.apim.portal.common.security.passwordcrypto;
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||||
|
|
||||||
import javax.servlet.Filter;
|
import javax.servlet.Filter;
|
||||||
import javax.servlet.FilterChain;
|
import javax.servlet.FilterChain;
|
||||||
@@ -13,16 +14,29 @@ import java.io.IOException;
|
|||||||
/**
|
/**
|
||||||
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
|
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
|
||||||
*
|
*
|
||||||
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE} 로 등록해
|
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE + 2} 로 등록해
|
||||||
* Lucy XSS 필터({@code MIN_VALUE + 1})보다 먼저 실행된다. 자세한 이유는
|
* MultipartFilter({@code MIN_VALUE + 1}) "이후", Lucy XSS 필터({@code MIN_VALUE + 3}) "이전"에
|
||||||
* {@link DecryptingRequestWrapper} 주석 참고.</p>
|
* 실행된다. 자세한 이유는 {@link DecryptingRequestWrapper} / {@link DecryptingMultipartRequestWrapper}
|
||||||
|
* 주석 참고 — 멀티파트(파일 업로드 동반) 요청은 MultipartFilter 가 만든
|
||||||
|
* {@code MultipartHttpServletRequest} 가 폼 필드를 자체 파싱해 반환하므로, 이 필터가 그보다
|
||||||
|
* 먼저 실행되면 비밀번호 파라미터가 복호화되지 않은 채로 컨트롤러까지 전달된다(법인가입처럼
|
||||||
|
* 파일 첨부와 비밀번호가 같은 폼에 있는 경우 재현).</p>
|
||||||
*
|
*
|
||||||
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
|
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
|
||||||
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
|
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</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
|
@Component
|
||||||
public class PasswordDecryptFilter implements Filter {
|
public class PasswordDecryptFilter implements Filter {
|
||||||
|
|
||||||
|
private static final String INTERNAL_API_PREFIX = "/internal/";
|
||||||
|
|
||||||
private final PasswordCryptoProperties properties;
|
private final PasswordCryptoProperties properties;
|
||||||
private final PasswordEnvelopeCodec codec;
|
private final PasswordEnvelopeCodec codec;
|
||||||
private final PasswordKeyStore keyStore;
|
private final PasswordKeyStore keyStore;
|
||||||
@@ -45,17 +59,23 @@ public class PasswordDecryptFilter implements Filter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||||
if (!"POST".equalsIgnoreCase(httpRequest.getMethod())) {
|
if (!"POST".equalsIgnoreCase(httpRequest.getMethod()) || isInternalApiRequest(httpRequest)) {
|
||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DecryptingRequestWrapper wrapper =
|
DecryptingRequestWrapper wrapper = httpRequest instanceof MultipartHttpServletRequest
|
||||||
new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
|
? new DecryptingMultipartRequestWrapper((MultipartHttpServletRequest) httpRequest, codec, keyStore, properties)
|
||||||
|
: new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
|
||||||
try {
|
try {
|
||||||
chain.doFilter(wrapper, response);
|
chain.doFilter(wrapper, response);
|
||||||
} finally {
|
} finally {
|
||||||
wrapper.consumeUsedKeys();
|
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;
|
package com.eactive.apim.portal.common.security.passwordcrypto;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비밀번호 계열 파라미터명 판별. {@code strict} 모드에서 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
|
* 비밀번호 계열 파라미터명 판별. {@code plaintext-policy} 가 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
|
||||||
*
|
*
|
||||||
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
|
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
|
||||||
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
|
* {@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;
|
package com.eactive.apim.portal.common.util;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Base64;
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component("encryptionUtil")
|
|
||||||
public class EncryptionUtil {
|
public class EncryptionUtil {
|
||||||
|
|
||||||
public static String generateNewPassword() {
|
public static String generateNewPassword() {
|
||||||
@@ -53,40 +40,4 @@ public class EncryptionUtil {
|
|||||||
return newpassword.toString();
|
return newpassword.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
|
||||||
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
|
|
||||||
|
|
||||||
private static final String ALGORITHM = "AES";
|
|
||||||
|
|
||||||
private SecretKeySpec createSecretKey() {
|
|
||||||
byte[] key = secretKey.getBytes(StandardCharsets.UTF_8);
|
|
||||||
return new SecretKeySpec(key, ALGORITHM);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
|
||||||
if (value == null || value.isEmpty()) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
SecretKeySpec key = createSecretKey();
|
|
||||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
|
||||||
|
|
||||||
byte[] encryptedBytes = cipher.doFinal(value.getBytes());
|
|
||||||
return Base64.getEncoder().encodeToString(encryptedBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String decrypt(String encrypted) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
|
||||||
if (encrypted == null || encrypted.isEmpty()) {
|
|
||||||
return encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
SecretKeySpec key = createSecretKey();
|
|
||||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
|
||||||
|
|
||||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encrypted));
|
|
||||||
return new String(decryptedBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
|||||||
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||||
|
import com.eactive.apim.portal.common.security.passwordcrypto.DecryptingMultipartRequestWrapper;
|
||||||
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
|
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
|
||||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -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
|
@Bean
|
||||||
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
|
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
|
||||||
PasswordDecryptFilter filter) {
|
PasswordDecryptFilter filter) {
|
||||||
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
|
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
|
||||||
registrationBean.setOrder(Integer.MIN_VALUE);
|
registrationBean.setOrder(Integer.MIN_VALUE + 2);
|
||||||
registrationBean.addUrlPatterns("/*");
|
registrationBean.addUrlPatterns("/*");
|
||||||
return registrationBean;
|
return registrationBean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야
|
||||||
|
// 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐).
|
||||||
@Bean
|
@Bean
|
||||||
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
||||||
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
||||||
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
|
||||||
registrationBean.setFilter(xssEscapeServletFilter);
|
registrationBean.setFilter(xssEscapeServletFilter);
|
||||||
registrationBean.setOrder(Integer.MIN_VALUE + 1);
|
registrationBean.setOrder(Integer.MIN_VALUE + 3);
|
||||||
registrationBean.addUrlPatterns("/*");
|
registrationBean.addUrlPatterns("/*");
|
||||||
return registrationBean;
|
return registrationBean;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-11
@@ -175,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
|||||||
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
|
||||||
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
|
||||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||||
|
*
|
||||||
|
* <p>버전닝이 꺼져 있으면(prod 포함 — {@link #isResourceVersioningEnabled()} 참고) URL 이
|
||||||
|
* 고정이라 장기 {@code max-age} 캐시를 쓰면 배포 후에도 브라우저가 옛 내용을 계속 쓸 위험이 있다.
|
||||||
|
* 이 경우 {@code no-cache}(매 요청 {@code If-Modified-Since} 조건부 재검증, 변경 없으면 304)로
|
||||||
|
* 신선도를 보장한다 — OHS 가 정적 리소스를 직접 서빙해도 Apache 가 파일 mtime 기준으로 동일하게
|
||||||
|
* Last-Modified/조건부 GET 을 처리하므로 WAS 와 동작이 어긋나지 않는다
|
||||||
|
* (배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리).</p>
|
||||||
*/
|
*/
|
||||||
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
boolean versioningOn = isResourceVersioningEnabled();
|
||||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
CacheControl cacheControl;
|
||||||
CacheControl cacheControl = isResourceCachingEnabled()
|
if (!isResourceCachingEnabled()) {
|
||||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
|
||||||
: CacheControl.noStore();
|
cacheControl = CacheControl.noStore();
|
||||||
|
} else if (versioningOn) {
|
||||||
|
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
|
||||||
|
} else {
|
||||||
|
cacheControl = CacheControl.noCache();
|
||||||
|
}
|
||||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||||
.addResourceLocations(locations)
|
.addResourceLocations(locations)
|
||||||
.setCacheControl(cacheControl)
|
.setCacheControl(cacheControl)
|
||||||
.resourceChain(isResourceCachingEnabled());
|
.resourceChain(isResourceCachingEnabled());
|
||||||
if (isResourceVersioningEnabled()) {
|
if (versioningOn) {
|
||||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||||
}
|
}
|
||||||
chain.addResolver(new PathResourceResolver());
|
chain.addResolver(new PathResourceResolver());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
|
* 정적자원 해시 버전닝 활성 여부. {@code app.resource-versioning.enabled} 값을 그대로 따른다.
|
||||||
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
|
*
|
||||||
|
* <p>과거 prod 프로파일은 이 토글과 무관하게 항상 ON 이었으나, OHS 가 정적 리소스를 직접
|
||||||
|
* 서빙하는 구조로 바뀌면서 OFF 로 전환했다 — 해시가 붙은 URL(main-<hash>.css)이 OHS
|
||||||
|
* 디스크의 무해시 원본과 어긋나 404 가 날 수 있어서다(prod.yml 에서 명시적으로 false 설정).
|
||||||
|
* 대신 캐시 신선도는 {@link #addStaticResourceHandler} 의 no-cache 조건부 재검증으로 보장한다.</p>
|
||||||
*/
|
*/
|
||||||
private boolean isResourceVersioningEnabled() {
|
private boolean isResourceVersioningEnabled() {
|
||||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return resourceVersioningEnabled;
|
return resourceVersioningEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
|
|||||||
public RestTemplate restTemplate() {
|
public RestTemplate restTemplate() {
|
||||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
|
||||||
// 연결 타임아웃: 10초
|
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
|
||||||
factory.setConnectTimeout(10000);
|
factory.setConnectTimeout(5000);
|
||||||
|
|
||||||
// 읽기 타임아웃: 30초
|
// 읽기 타임아웃: 5초
|
||||||
factory.setReadTimeout(30000);
|
factory.setReadTimeout(5000);
|
||||||
|
|
||||||
return new RestTemplate(factory);
|
return new RestTemplate(factory);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("//notitest")
|
@RequestMapping("/djb/notitest")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class NotiTestController {
|
public class NotiTestController {
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ gateway:
|
|||||||
portal:
|
portal:
|
||||||
# auth-virtual-code: 654321
|
# auth-virtual-code: 654321
|
||||||
security:
|
security:
|
||||||
# 개발 서버는 HTTPS 가 아니라 브라우저 Web Crypto 를 쓸 수 없다.
|
# 개발 서버는 HTTP(비 secure context)라 crypto.subtle 이 없다. software-fallback(forge)으로 암호화한다.
|
||||||
password-encrypt:
|
password-encrypt:
|
||||||
enabled: false
|
enabled: true
|
||||||
|
# 개발 서버는 원격 오리진 HTTP → crypto.subtle 이 없다. 기본값인 software-fallback(forge)으로 암호화된다.
|
||||||
|
plaintext-policy: permissive
|
||||||
dev:
|
dev:
|
||||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||||
hot-reload-pages: true
|
hot-reload-pages: true
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ spring:
|
|||||||
cachecontrol:
|
cachecontrol:
|
||||||
max-age: 86400
|
max-age: 86400
|
||||||
public: true
|
public: true
|
||||||
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
|
# 정적자원 콘텐츠 해시 버전닝: OHS 가 정적 리소스(css/js/img 등)를 직접 서빙하는 구조에서는
|
||||||
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
|
# 해시 URL(main-<hash>.css)이 OHS 디스크의 무해시 원본과 어긋나 404 위험이 있어 OFF 로 전환했다
|
||||||
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
|
# (아래 app.resource-versioning.enabled: false, PortalConfigWebDispatcherServlet 참고).
|
||||||
|
# 캐시 무효화는 대신 no-cache(매 요청 If-Modified-Since 조건부 재검증)로 보장한다.
|
||||||
|
# 배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리
|
||||||
jpa:
|
jpa:
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
@@ -28,7 +30,7 @@ spring:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
resource-versioning:
|
resource-versioning:
|
||||||
enabled: true
|
enabled: false
|
||||||
resource-caching:
|
resource-caching:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
@@ -41,7 +43,10 @@ server:
|
|||||||
portal:
|
portal:
|
||||||
security:
|
security:
|
||||||
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
|
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
|
||||||
# strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가).
|
|
||||||
password-encrypt:
|
password-encrypt:
|
||||||
enabled: true
|
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
|
# auth-virtual-code: 654321
|
||||||
security:
|
security:
|
||||||
# HTTPS 구간이라 전송암호화를 켠다. strict 는 운영 승격 전 검증 후 판단.
|
# HTTPS 구간이라 전송암호화를 켠다. plaintext-policy 승격(ENFORCE)은 운영 승격 전 검증 후 판단.
|
||||||
password-encrypt:
|
password-encrypt:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
plaintext-policy: permissive
|
||||||
dev:
|
dev:
|
||||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||||
hot-reload-pages: true
|
hot-reload-pages: true
|
||||||
|
|||||||
@@ -155,15 +155,50 @@ portal:
|
|||||||
|
|
||||||
security:
|
security:
|
||||||
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
|
# 비밀번호 전송암호화(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:
|
password-encrypt:
|
||||||
|
# 마스터 스위치. false 면 필터·키 발급 엔드포인트가 모두 무동작이고 화면은 평문 전송한다.
|
||||||
|
# 기본은 꺼둔다 — 켜는 것은 프로파일 yml 의 판단.
|
||||||
enabled: false
|
enabled: false
|
||||||
# REQUEST(폼당 1회용) | SESSION(세션당) | SERVER(서버 고정 + TTL 로테이션)
|
|
||||||
|
# RSA 개인키 보관 범위.
|
||||||
|
# REQUEST - 폼 진입마다 1회용 키 발급, 요청 1회 사용 후 폐기. 재전송 차단이 가장 강하다.
|
||||||
|
# SESSION - 세션 단위 보관. 세션 복제 환경에 유리.
|
||||||
|
# SERVER - 서버 고정 키쌍 + TTL 로테이션. 무상태라 단순하지만 재전송 방지 수단이 없다.
|
||||||
key-scope: REQUEST
|
key-scope: REQUEST
|
||||||
|
|
||||||
|
# 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기.
|
||||||
key-ttl-seconds: 300
|
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
|
rsa-key-size: 2048
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
|
|||||||
@@ -1247,7 +1247,7 @@ hr {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
|
.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 {
|
.mobile-drawer .drawer-welcome.authenticated {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -2574,7 +2574,7 @@ hr {
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
.btn-success:hover {
|
.btn-success:hover {
|
||||||
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@@ -2582,7 +2582,7 @@ hr {
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
background: rgb(255, 70.8, 70.8);
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@@ -2848,7 +2848,7 @@ hr {
|
|||||||
.action-btn-delete:hover {
|
.action-btn-delete:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
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 {
|
.action-btn-delete:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -2966,7 +2966,7 @@ hr {
|
|||||||
background: #a4d6ea;
|
background: #a4d6ea;
|
||||||
}
|
}
|
||||||
.btn-input-action.btn-change:hover {
|
.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);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||||
}
|
}
|
||||||
@@ -3041,7 +3041,7 @@ hr {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.btn-action-primary:hover {
|
.btn-action-primary:hover {
|
||||||
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
|
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -3091,7 +3091,7 @@ hr {
|
|||||||
}
|
}
|
||||||
.status-badge.status-processing {
|
.status-badge.status-processing {
|
||||||
background: rgba(255, 217, 61, 0.1);
|
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 {
|
.status-badge.status-failed {
|
||||||
background: rgba(255, 107, 107, 0.1);
|
background: rgba(255, 107, 107, 0.1);
|
||||||
@@ -3131,7 +3131,7 @@ hr {
|
|||||||
}
|
}
|
||||||
.status-badge-header.status-processing {
|
.status-badge-header.status-processing {
|
||||||
background: rgba(255, 217, 61, 0.1);
|
background: rgba(255, 217, 61, 0.1);
|
||||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
color: rgb(221.2, 177.8721649485, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-sm {
|
.badge-sm {
|
||||||
@@ -4319,7 +4319,7 @@ select.form-control {
|
|||||||
.file-upload-wrapper .file-remove-btn:hover {
|
.file-upload-wrapper .file-remove-btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
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 {
|
.file-upload-wrapper .file-remove-btn:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -4640,7 +4640,7 @@ select.form-control {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.form-actions--with-withdrawal .withdrawal-link:hover {
|
.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 {
|
.form-actions--with-withdrawal .withdrawal-link img {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@@ -4795,7 +4795,7 @@ select.form-control {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.notice-content-box a:hover {
|
.notice-content-box a:hover {
|
||||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
color: rgb(0, 65.7, 162);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row--content .form-label-wrapper {
|
.form-row--content .form-label-wrapper {
|
||||||
@@ -5067,6 +5067,8 @@ select.form-control {
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
.modal-body p {
|
.modal-body p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -5733,7 +5735,7 @@ select.form-control {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.drawer-logout-btn:hover {
|
.drawer-logout-btn:hover {
|
||||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
background: rgb(255, 70.8, 70.8);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||||
}
|
}
|
||||||
@@ -6447,7 +6449,7 @@ select.form-control {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.list-table-btn--default:hover {
|
.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 {
|
.list-table-btn--primary {
|
||||||
background-color: #ecf0fa;
|
background-color: #ecf0fa;
|
||||||
@@ -6455,7 +6457,7 @@ select.form-control {
|
|||||||
color: #2a69de;
|
color: #2a69de;
|
||||||
}
|
}
|
||||||
.list-table-btn--primary:hover {
|
.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 {
|
.list-table-btn--secondary {
|
||||||
background-color: #f5f5f4;
|
background-color: #f5f5f4;
|
||||||
@@ -6463,7 +6465,7 @@ select.form-control {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.list-table-btn--secondary:hover {
|
.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 {
|
.list-table-btn--danger {
|
||||||
background-color: #fbe7e9;
|
background-color: #fbe7e9;
|
||||||
@@ -6471,7 +6473,7 @@ select.form-control {
|
|||||||
color: #bb1026;
|
color: #bb1026;
|
||||||
}
|
}
|
||||||
.list-table-btn--danger:hover {
|
.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 {
|
.table-pagination {
|
||||||
@@ -7121,7 +7123,7 @@ select.form-control {
|
|||||||
.alert.alert-error {
|
.alert.alert-error {
|
||||||
background: rgba(255, 107, 107, 0.1);
|
background: rgba(255, 107, 107, 0.1);
|
||||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
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;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.alert.alert-error svg {
|
.alert.alert-error svg {
|
||||||
@@ -7135,7 +7137,7 @@ select.form-control {
|
|||||||
.alert.alert-success {
|
.alert.alert-success {
|
||||||
background: rgba(107, 207, 127, 0.1);
|
background: rgba(107, 207, 127, 0.1);
|
||||||
border: 1px solid rgba(107, 207, 127, 0.3);
|
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 {
|
.alert.alert-info {
|
||||||
background: rgba(0, 73, 180, 0.1);
|
background: rgba(0, 73, 180, 0.1);
|
||||||
@@ -11549,10 +11551,10 @@ body.index-page-body {
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
.login-button:hover {
|
.login-button:hover {
|
||||||
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
|
background: rgb(25.65, 70.3, 173.85);
|
||||||
}
|
}
|
||||||
.login-button:active {
|
.login-button:active {
|
||||||
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
|
background: rgb(24.3, 66.6, 164.7);
|
||||||
}
|
}
|
||||||
.login-button:disabled {
|
.login-button:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -11595,10 +11597,10 @@ body.index-page-body {
|
|||||||
border-bottom-right-radius: 8px;
|
border-bottom-right-radius: 8px;
|
||||||
}
|
}
|
||||||
.login-links-container .link-btn:hover {
|
.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 {
|
.login-links-container .link-btn:active {
|
||||||
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
|
background: rgb(205.22, 215.7, 241.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-alert {
|
.login-alert {
|
||||||
@@ -12107,12 +12109,12 @@ body.index-page-body {
|
|||||||
}
|
}
|
||||||
.auth-request-button:hover,
|
.auth-request-button:hover,
|
||||||
.auth-verify-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;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
.auth-request-button:active,
|
.auth-request-button:active,
|
||||||
.auth-verify-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-request-button:disabled,
|
||||||
.auth-verify-button:disabled {
|
.auth-verify-button:disabled {
|
||||||
@@ -12161,10 +12163,10 @@ body.index-page-body {
|
|||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
}
|
}
|
||||||
.account-recovery-card .form-actions .cancel-button:hover {
|
.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 {
|
.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 {
|
.account-recovery-card .form-actions .submit-button {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
@@ -12174,7 +12176,7 @@ body.index-page-body {
|
|||||||
background: rgb(6, 54, 125);
|
background: rgb(6, 54, 125);
|
||||||
}
|
}
|
||||||
.account-recovery-card .form-actions .submit-button:active {
|
.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 {
|
.account-recovery-card .form-actions .submit-button:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -12410,7 +12412,7 @@ body.index-page-body {
|
|||||||
transition: color 0.3s ease;
|
transition: color 0.3s ease;
|
||||||
}
|
}
|
||||||
.result-info-box .info-text .info-link:hover {
|
.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) {
|
@media (max-width: 576px) {
|
||||||
.result-info-box .info-text {
|
.result-info-box .info-text {
|
||||||
@@ -17610,7 +17612,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.btn-copy-action:hover {
|
.btn-copy-action:hover {
|
||||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.btn-copy-action {
|
.btn-copy-action {
|
||||||
@@ -17637,7 +17639,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.btn-view-secret:hover {
|
.btn-view-secret:hover {
|
||||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||||
}
|
}
|
||||||
.btn-view-secret svg {
|
.btn-view-secret svg {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -17822,7 +17824,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.btn-copy-action:hover {
|
.btn-copy-action:hover {
|
||||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||||
}
|
}
|
||||||
.btn-view-secret {
|
.btn-view-secret {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -17838,7 +17840,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
.btn-view-secret:hover {
|
.btn-view-secret:hover {
|
||||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||||
}
|
}
|
||||||
#revealedSecretBox {
|
#revealedSecretBox {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -18111,7 +18113,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-copy:hover {
|
.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 {
|
.detail-wrap .dt-btn-copy svg {
|
||||||
color: #2a69de;
|
color: #2a69de;
|
||||||
@@ -18292,7 +18294,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-gray:hover {
|
.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 {
|
.detail-wrap .dt-btn-red {
|
||||||
width: 156px;
|
width: 156px;
|
||||||
@@ -18310,7 +18312,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
.detail-wrap .dt-btn-red:hover {
|
.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 {
|
.detail-wrap .dt-btn-blue {
|
||||||
width: 156px;
|
width: 156px;
|
||||||
@@ -19884,7 +19886,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-inquiry-list:hover {
|
.btn-inquiry-list:hover {
|
||||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||||
}
|
}
|
||||||
.btn-inquiry-list:active {
|
.btn-inquiry-list:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
@@ -19917,7 +19919,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-inquiry-edit:hover {
|
.btn-inquiry-edit:hover {
|
||||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
background: rgb(0, 69.35, 171);
|
||||||
}
|
}
|
||||||
.btn-inquiry-edit:active {
|
.btn-inquiry-edit:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
@@ -19950,7 +19952,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-inquiry-delete:hover {
|
.btn-inquiry-delete:hover {
|
||||||
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
|
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
|
||||||
}
|
}
|
||||||
.btn-inquiry-delete:active {
|
.btn-inquiry-delete:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
@@ -20022,7 +20024,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
.file-upload-inline .btn-remove-file-inline:hover {
|
.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 {
|
.file-upload-inline .btn-remove-file-inline svg {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -20054,7 +20056,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.file-upload-inline .btn-file-attach:hover {
|
.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 {
|
.file-upload-inline .btn-file-attach svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@@ -20114,7 +20116,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.inquiry-form-container .form-actions .btn-secondary:hover {
|
.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 {
|
.inquiry-form-container .form-actions .btn-primary {
|
||||||
background: #0049b4;
|
background: #0049b4;
|
||||||
@@ -20122,7 +20124,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.inquiry-form-container .form-actions .btn-primary:hover {
|
.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 {
|
.inquiry-form-container .file-upload-inline .file-input-display {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
@@ -20911,7 +20913,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.djb-board-write-container .form-actions .btn-submit:hover {
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.djb-board-write-container .form-actions .btn-submit {
|
.djb-board-write-container .form-actions .btn-submit {
|
||||||
@@ -21447,7 +21449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
.org-file-remove:hover {
|
.org-file-remove:hover {
|
||||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
background: rgb(255, 70.8, 70.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.org-file-notice {
|
.org-file-notice {
|
||||||
@@ -22488,7 +22490,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
.status-indicator.status-active {
|
.status-indicator.status-active {
|
||||||
background-color: rgba(107, 207, 127, 0.1);
|
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 {
|
.status-indicator.status-active .status-dot {
|
||||||
background-color: #6BCF7F;
|
background-color: #6BCF7F;
|
||||||
@@ -25273,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
color: #666;
|
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 {
|
.btn-search {
|
||||||
background: #0049B4;
|
background: #0049B4;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -27323,9 +27353,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
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 {
|
.api-status .as-maint-card .as-maint-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -27337,7 +27364,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--as-text);
|
color: var(--as-info);
|
||||||
}
|
}
|
||||||
.api-status .as-maint-card .as-schedule {
|
.api-status .as-maint-card .as-schedule {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -27351,10 +27378,6 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
border-radius: var(--as-pill);
|
border-radius: var(--as-pill);
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.api-status .as-maint-card .as-maint-body {
|
||||||
margin: 14px 0 12px;
|
margin: 14px 0 12px;
|
||||||
font-size: 14px;
|
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 인라인 주입)
|
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||||
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||||
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
|
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
|
||||||
*
|
*
|
||||||
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
||||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||||
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return; // 모듈 미사용 페이지
|
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
|
// DOM Elements
|
||||||
const searchInput = document.getElementById('apiSearch');
|
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)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
|
* 서버(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) {
|
(function (global) {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -20,26 +27,52 @@
|
|||||||
var cfg = global.__PASSWORD_CRYPTO__ || {};
|
var cfg = global.__PASSWORD_CRYPTO__ || {};
|
||||||
var PREFIX = 'ENC1.';
|
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() {
|
function available() {
|
||||||
return !!(cfg.enabled
|
return !!(cfg.enabled
|
||||||
&& global.isSecureContext
|
&& cfg.keyUrl
|
||||||
&& global.crypto
|
&& global.crypto
|
||||||
&& global.crypto.subtle
|
&& global.crypto.getRandomValues
|
||||||
&& global.fetch
|
&& global.fetch
|
||||||
&& global.Promise);
|
&& global.Promise
|
||||||
|
&& (hasSubtle() || hasForge()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 인코딩 유틸 ----------
|
// ---------- 인코딩 유틸 ----------
|
||||||
|
|
||||||
function toBase64Url(buffer) {
|
function bytesToBinary(bytes) {
|
||||||
var bytes = new Uint8Array(buffer);
|
|
||||||
var binary = '';
|
var binary = '';
|
||||||
for (var i = 0; i < bytes.length; i++) {
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
binary += String.fromCharCode(bytes[i]);
|
binary += String.fromCharCode(bytes[i]);
|
||||||
}
|
}
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryToBase64Url(binary) {
|
||||||
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toBase64Url(buffer) {
|
||||||
|
return binaryToBase64Url(bytesToBinary(new Uint8Array(buffer)));
|
||||||
|
}
|
||||||
|
|
||||||
function base64ToBytes(base64) {
|
function base64ToBytes(base64) {
|
||||||
var binary = global.atob(base64);
|
var binary = global.atob(base64);
|
||||||
var bytes = new Uint8Array(binary.length);
|
var bytes = new Uint8Array(binary.length);
|
||||||
@@ -49,19 +82,161 @@
|
|||||||
return bytes;
|
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 } */
|
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
|
||||||
var cachedKey = null;
|
var cachedKey = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서버에서 공개키를 받아 import 한다.
|
* 서버에서 공개키를 받아 현재 엔진 형식으로 import 한다.
|
||||||
*
|
*
|
||||||
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
|
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
|
||||||
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
|
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
|
||||||
* 생성하게 만들지 않기 위함이다.
|
* 생성하게 만들지 않기 위함이다.
|
||||||
*/
|
*/
|
||||||
function loadKey() {
|
function loadKey(currentEngine) {
|
||||||
if (cachedKey && cachedKey.expiresAt > Date.now()) {
|
if (cachedKey && cachedKey.expiresAt > Date.now()) {
|
||||||
return global.Promise.resolve(cachedKey);
|
return global.Promise.resolve(cachedKey);
|
||||||
}
|
}
|
||||||
@@ -79,13 +254,7 @@
|
|||||||
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
|
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
|
||||||
throw new Error('전송암호화 비활성 상태의 응답');
|
throw new Error('전송암호화 비활성 상태의 응답');
|
||||||
}
|
}
|
||||||
return global.crypto.subtle.importKey(
|
return currentEngine.importKey(data.publicKey).then(function (publicKey) {
|
||||||
'spki',
|
|
||||||
base64ToBytes(data.publicKey),
|
|
||||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
).then(function (publicKey) {
|
|
||||||
var key = { keyId: data.keyId, publicKey: publicKey };
|
var key = { keyId: data.keyId, publicKey: publicKey };
|
||||||
if (data.keyScope && data.keyScope !== 'REQUEST') {
|
if (data.keyScope && data.keyScope !== 'REQUEST') {
|
||||||
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
|
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
|
||||||
@@ -112,45 +281,20 @@
|
|||||||
var v = values[name];
|
var v = values[name];
|
||||||
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
|
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 || {});
|
return global.Promise.resolve(values || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
var subtle = global.crypto.subtle;
|
return loadKey(currentEngine).then(function (key) {
|
||||||
var context = {};
|
return currentEngine.seal(key.publicKey, key.keyId, names, values);
|
||||||
|
}).then(function (envelopes) {
|
||||||
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) {
|
|
||||||
var out = {};
|
var out = {};
|
||||||
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
|
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;
|
return out;
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
// 암호화 실패는 화면을 막지 않는다. 서버 strict 가 꺼져 있으면 평문으로 처리된다.
|
// 암호화 실패는 화면을 막지 않는다. 서버 정책이 ENFORCE 가 아니면 평문으로 처리된다.
|
||||||
if (global.console && global.console.warn) {
|
if (global.console && global.console.warn) {
|
||||||
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
|
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 {HTMLFormElement} form
|
||||||
* @param {string[]} fieldNames
|
* @param {string[]} fieldNames
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -171,7 +393,7 @@
|
|||||||
var targets = {};
|
var targets = {};
|
||||||
var elements = {};
|
var elements = {};
|
||||||
fieldNames.forEach(function (name) {
|
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) {
|
if (el && typeof el.value === 'string' && el.value.length > 0) {
|
||||||
elements[name] = el;
|
elements[name] = el;
|
||||||
targets[name] = el.value;
|
targets[name] = el.value;
|
||||||
@@ -181,14 +403,136 @@
|
|||||||
return global.Promise.resolve();
|
return global.Promise.resolve();
|
||||||
}
|
}
|
||||||
return encryptValues(targets).then(function (encrypted) {
|
return encryptValues(targets).then(function (encrypted) {
|
||||||
|
var restores = [];
|
||||||
Object.keys(elements).forEach(function (name) {
|
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 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
|
||||||
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
|
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
|
||||||
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
|
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
|
||||||
|
// 경고 팝업도 같은 시점이어야 custom-popups.js 와 팝업 프래그먼트가 준비된 뒤에 뜬다.
|
||||||
if (global.document) {
|
if (global.document) {
|
||||||
if (global.document.readyState === 'complete') {
|
if (global.document.readyState === 'complete') {
|
||||||
bindDeclarativeForms();
|
start();
|
||||||
} else {
|
} else {
|
||||||
global.addEventListener('load', bindDeclarativeForms);
|
global.addEventListener('load', start);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
global.portalPasswordCrypto = {
|
global.portalPasswordCrypto = {
|
||||||
available: available,
|
available: available,
|
||||||
|
engine: function () {
|
||||||
|
var current = available() ? engine() : null;
|
||||||
|
return current ? current.name : null;
|
||||||
|
},
|
||||||
encryptForm: encryptForm,
|
encryptForm: encryptForm,
|
||||||
encryptValues: encryptValues
|
encryptValues: encryptValues
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,7 +91,10 @@
|
|||||||
var seg = document.getElementById('tfaSegment');
|
var seg = document.getElementById('tfaSegment');
|
||||||
seg.innerHTML = '';
|
seg.innerHTML = '';
|
||||||
self._maskedByType = {};
|
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) {
|
channels.forEach(function (ch, idx) {
|
||||||
self._maskedByType[ch.type] = ch.masked;
|
self._maskedByType[ch.type] = ch.masked;
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
|
|||||||
@@ -195,6 +195,11 @@
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: $line-height-normal;
|
line-height: $line-height-normal;
|
||||||
|
// 한글은 기본값(normal)이면 음절 단위로 끊겨 "막지는 못합니 / 다" 처럼 잘린다.
|
||||||
|
// keep-all 로 어절(띄어쓰기) 단위 줄바꿈을 강제한다. 대신 공백 없는 긴 문자열(URL·키값)이
|
||||||
|
// 넘칠 수 있어 overflow-wrap 으로 그때만 강제 분리한다.
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -118,6 +118,36 @@
|
|||||||
color: #666;
|
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 {
|
.btn-search {
|
||||||
background: #0049B4;
|
background: #0049B4;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -692,4 +722,4 @@
|
|||||||
.stats-progress {
|
.stats-progress {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -529,8 +529,6 @@
|
|||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
|
|
||||||
&.is-ongoing { border-left-color: var(--as-warn); }
|
|
||||||
|
|
||||||
.as-maint-head {
|
.as-maint-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -539,7 +537,7 @@
|
|||||||
flex-wrap: wrap;
|
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 {
|
.as-schedule {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -554,8 +552,6 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
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-body { margin: 14px 0 12px; font-size: 14px; line-height: 1.6; color: var(--as-text-3); }
|
||||||
|
|
||||||
.as-maint-meta {
|
.as-maint-meta {
|
||||||
|
|||||||
@@ -41,10 +41,11 @@
|
|||||||
<div class="search-filter-row">
|
<div class="search-filter-row">
|
||||||
<!-- 일별: 날짜 범위 -->
|
<!-- 일별: 날짜 범위 -->
|
||||||
<div class="date-range-picker" id="dailyFilter">
|
<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')}">
|
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
||||||
<span class="date-separator">-</span>
|
<input type="hidden" id="endDate"
|
||||||
<input type="date" id="endDate" class="date-input"
|
|
||||||
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<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>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<path d="m21 21-4.35-4.35"></path>
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
@@ -335,6 +336,8 @@
|
|||||||
let currentMode = 'DAILY';
|
let currentMode = 'DAILY';
|
||||||
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
||||||
const MAX_DATE_RANGE_DAYS = 40;
|
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세그먼트) ──
|
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
@@ -460,16 +463,70 @@
|
|||||||
return meta ? meta.getAttribute('content') : '';
|
return meta ? meta.getAttribute('content') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate date range (max 40 days)
|
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
|
||||||
function validateDateRange(startDate, endDate) {
|
function validateDateRange(startDate, endDate) {
|
||||||
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||||
var start = new Date(startDate), end = new Date(endDate);
|
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
|
||||||
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
|
||||||
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||||
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
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 };
|
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() {
|
function buildPayload() {
|
||||||
return {
|
return {
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
@@ -505,8 +562,10 @@
|
|||||||
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
||||||
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
||||||
}
|
}
|
||||||
if (p.startDate) $('#startDate').val(p.startDate);
|
if (validateDateRange(p.startDate, p.endDate).valid) {
|
||||||
if (p.endDate) $('#endDate').val(p.endDate);
|
$('#startDate').val(p.startDate);
|
||||||
|
$('#endDate').val(p.endDate);
|
||||||
|
}
|
||||||
if (p.month) $('#month').val(p.month);
|
if (p.month) $('#month').val(p.month);
|
||||||
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
||||||
return true;
|
return true;
|
||||||
@@ -563,12 +622,12 @@
|
|||||||
// Event Handlers
|
// Event Handlers
|
||||||
$('#btnSearch').on('click', searchStatistics);
|
$('#btnSearch').on('click', searchStatistics);
|
||||||
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
||||||
$('#startDate, #endDate').on('change', searchStatistics);
|
|
||||||
$('#month').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();
|
searchStatistics();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -577,4 +636,4 @@
|
|||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
||||||
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
||||||
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
|
- 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) 재사용.
|
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
|
||||||
*/-->
|
*/-->
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
|
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
|
||||||
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
||||||
|
window.API_SELECTOR_PAGE_SIZE = /*[[${@apiListProperties.pageSize}]]*/ 15;
|
||||||
</script>
|
</script>
|
||||||
<script th:src="@{/js/api-selector.js}"></script>
|
<script th:src="@{/js/api-selector.js}"></script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|||||||
@@ -32,12 +32,16 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 브라우저가 Web Crypto 를
|
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 봉투를 만들 수단이 전혀 없으면
|
||||||
쓸 수 없으면(로컬 HTTP 등) 모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다. -->
|
모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다.
|
||||||
|
forgeUrl 은 crypto.subtle 을 못 쓰는 환경(원격 오리진 HTTP)에서만 동적으로 로드된다. -->
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
window.__PASSWORD_CRYPTO__ = {
|
window.__PASSWORD_CRYPTO__ = {
|
||||||
enabled: /*[[${passwordCryptoEnabled}]]*/ false,
|
enabled: /*[[${passwordCrypto.enabled}]]*/ false,
|
||||||
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json'
|
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>
|
||||||
<script th:src="@{/js/password-crypto.js}"></script>
|
<script th:src="@{/js/password-crypto.js}"></script>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!-- fragment/popup/customPopups.html -->
|
<!-- fragment/popup/customPopups.html -->
|
||||||
<div th:fragment="customPopups">
|
<div th:fragment="customPopups">
|
||||||
<div th:replace="fragment/popup/customPopup :: #customAlert"></div>
|
<div th:replace="~{fragment/popup/customPopup :: #customAlert}"></div>
|
||||||
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
|
<div th:replace="~{fragment/popup/emailValidationPopup :: #emailValidationPopup}"></div>
|
||||||
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
|
<div th:replace="~{fragment/popup/customPopup2 :: #customConfirm}"></div>
|
||||||
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
|
<div th:replace="~{fragment/popup/passwordInputPopup :: passwordInputPopup}"></div>
|
||||||
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
|
<div th:replace="~{fragment/popup/terminateRequestPopup :: terminateRequestPopup}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
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">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
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">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</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>
|
||||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||||
<section layout:fragment="pagePopups"></section>
|
<section layout:fragment="pagePopups"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
<script th:src="@{/js/popup/two-factor-auth.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">
|
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">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</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>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
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">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<th:block layout:fragment="script">
|
<th:block layout:fragment="script">
|
||||||
</th:block>
|
</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>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
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">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="loading-overlay" style="display: none;">
|
<div class="loading-overlay" style="display: none;">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</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">
|
<div class="container">
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</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>
|
||||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||||
<section layout:fragment="pagePopups"></section>
|
<section layout:fragment="pagePopups"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
<script th:src="@{/js/popup/two-factor-auth.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">
|
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">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,31 +3,31 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
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">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- header -->
|
<!-- header -->
|
||||||
<header th:replace="fragment/admin_header :: headerFragment"></header>
|
<header th:replace="~{fragment/admin_header :: headerFragment}"></header>
|
||||||
<th:block th:replace="fragment/admin_header :: headerScript"></th:block>
|
<th:block th:replace="~{fragment/admin_header :: headerScript}"></th:block>
|
||||||
<nav th:replace="fragment/header :: navFragment"></nav>
|
<nav th:replace="~{fragment/header :: navFragment}"></nav>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- csrf -->
|
<!-- csrf -->
|
||||||
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<div>
|
<div>
|
||||||
<aside th:replace="fragment/menu :: menuFragment"></aside>
|
<aside th:replace="~{fragment/menu :: menuFragment}"></aside>
|
||||||
<th:block th:replace="fragment/menu :: menuScript"></th:block>
|
<th:block th:replace="~{fragment/menu :: menuScript}"></th:block>
|
||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<div layout:fragment="contentFragment">
|
<div layout:fragment="contentFragment">
|
||||||
</div>
|
</div>
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
<h2>2. 프래그먼트 대체</h2>
|
<h2>2. 프래그먼트 대체</h2>
|
||||||
<header th:replace="fragment/header :: headerFragment"></header>
|
<header th:replace="~{fragment/header :: headerFragment}"></header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
<h2>14. 프래그먼트</h2>
|
<h2>14. 프래그먼트</h2>
|
||||||
<div th:include="fragment/footer :: footerFragment">여기에 푸터가 들어갑니다</div>
|
<div th:include="~{fragment/footer :: footerFragment}">여기에 푸터가 들어갑니다</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="example">
|
<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