20 Commits

Author SHA1 Message Date
Rinjae(gf63) 094336ebf1 - Gradle 실행 설정 추가 (clean, compileJava)
eapim-portal CI / build (push) Waiting to run
eapim-portal Test / test (push) Waiting to run
- API 편집기 닫기 전 변경사항 알림/확인 로직 추가
- 저장 성공/초기 로드 시점 기준 스냅샷 로직 및 UI 처리를 반영
2026-09-11 18:38:50 +09:00
Rinjae a310b671e2 API 통계 날짜 선택 로직 및 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 최대 40일 범위 제한 및 1년 조회 가능 기간 검증
- Date-range Picker UI 추가 및 서버 동기화 로직 구현
- Controller, DTO 유효성 테스트 및 Playwright 테스트 작성
2026-09-09 16:25:00 +09:00
Rinjae d9373b00f0 Moment.js 업그레이드 및 호환성 테스트 추가:
- 최신 버전(2.30.1)으로 업데이트 및 CVE-2022-24785 대응
- 브라우저 기반 date-range 동작 검증 및 Playwright 테스트 추가
- 윤년/서머타임 처리 및 CommonJS 보안 회귀 검증
2026-09-09 16:00:13 +09:00
Rinjae 09bc3a65f5 API Selector 전역변수 사용 수정: window → globalThis
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 14:36:55 +09:00
Rinjae 1d81681e70 API 목록 페이징 로직 테스트 및 페이지 크기 설정 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 기본 페이지 크기 설정 ApiListProperties 도입
- Controller, 템플릿, JS에 설정값 연결 및 테스트 추가
2026-09-09 12:02:08 +09:00
Rinjae d4bafa706c 로고 파일 이름 변경: logo-jjb_white.pnglogo-jjb-white.png
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 10:00:35 +09:00
Rinjae 60f9f89d90 2FA 채널 정렬 로직 추가: SMS를 우선 표시 및 기본 선택 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-09 09:51:44 +09:00
Rinjae 8ecfaf4945 쿠키 및 CSRF 토큰 유지 여부 검사 스크립트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 새로운 익명 세션에서 두 서버 간 응답 비교 기능 구현
- Python 기반 검사기 `cookie.py` 작성 및 주요 옵션 제공
2026-09-08 10:31:58 +09:00
Rinjae fa736a36ab Thymeleaf 호환성 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Mvc/Form/Security/Expression 관련 템플릿 및 테스트 구현
- 의존성 검증 및 업그레이드 스크립트 추가 (Thymeleaf 3.1.5 적용)
2026-09-07 15:29:45 +09:00
Rinjae b7869ad6e6 OHS 설정 업데이트:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 정적 리소스 경로 `/apps/portal-static`으로 변경
- favicon 및 SetHandler 설정 추가
2026-09-07 11:04:20 +09:00
Rinjae 982187298c 정적 리소스 서빙 및 빌드 프로세스 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- OHS 환경 대응 정적자원 해시 버전닝 OFF 설정 반영
- eapim-portal-static.zip 생성 및 CI/CD 추가
- OHS 설정 샘플 가이드 추가
2026-09-07 10:55:43 +09:00
Rinjae 8118dba74d CSS 소스맵 업데이트: 최신 Sass 구성 반영 및 맵핑 갱신
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-03 17:05:47 +09:00
Rinjae-gf63 97489d0361 - PasswordDecryptFilter에 /internal/** 경로 제외 처리 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 내부 API 요청 필터 동작/암호화 정책 충돌 방지
2026-09-03 09:19:46 +09:00
Rinjae-gf63 924c00968c - 멀티파트 요청 지원 복호화 래퍼 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PasswordDecryptFilter 순서 조정(MultipartFilter 이후 실행)
- 필터와 XSS Escape 필터 순서 정보 및 주석 보완
2026-09-02 18:35:44 +09:00
Rinjae 53c3b0ee7e 포탈 암호화 모듈 추가: forge-crypto.min.js 라이브러리 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-02 16:59:25 +09:00
Rinjae b2787882bf 비밀번호 암호화 로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- name 재지정 및 hidden 필드로 봉투 전송 처리 추가
- 폼 제출 후 원상복구 스케줄링 로직 구현
2026-09-02 09:50:56 +09:00
Rinjae aafa7ac351 NotiTestController 요청 매핑 경로 수정: //notitest/djb/notitest
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-02 09:35:50 +09:00
Rinjae b7477e10a1 feats/security 브랜치 병합 - 비밀번호 전송 암호화(RSA-OAEP + AES-GCM) 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-01 15:08:21 +09:00
Rinjae 11f1dae1f3 Thymeleaf 프래그먼트 경로 표기 오류 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모든 th:replace/th:include 경로 ~{}로 변경
2026-09-01 15:07:23 +09:00
Rinjae d275aa12c4 Admin GatewayClient 다중 base URL 및 failover 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin.base-url 다중 주소 지원 및 순차 failover 구현
- RestTemplate 타임아웃 설정 변경 (연결/읽기: 5초로 단축)
2026-09-01 14:50:39 +09:00
77 changed files with 3490 additions and 408 deletions
@@ -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
View File
@@ -74,8 +74,9 @@ pipeline {
set -eu
cd build/libs
sha256sum eapim-portal.war > eapim-portal.war.sha256
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
stash name: 'war', includes: 'build/libs/eapim-portal.war'
}
}
+2 -1
View File
@@ -96,8 +96,9 @@ pipeline {
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
sha256sum eapim-portal-static.zip > eapim-portal-static.zip.sha256
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5,build/libs/eapim-portal-static.zip,build/libs/eapim-portal-static.zip.sha256', fingerprint: true
}
}
}
+22 -6
View File
@@ -87,6 +87,10 @@ dependencies {
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
}
// WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265).
// 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환.
// 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가).
implementation 'ognl:ognl:3.3.5'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation('org.springframework.boot:spring-boot-starter-cache')
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
@@ -190,13 +194,13 @@ ext {
// 2.18.x 마지막 패치를 쓴다.
set('jackson-bom.version', '2.18.10')
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
// org.thymeleaf.spring5 + javax.servlet 그대로다.
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
set('thymeleaf.version', '3.1.4.RELEASE')
set('thymeleaf.version', '3.1.5.RELEASE')
set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE')
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
@@ -315,6 +319,18 @@ war {
classpath = excludeLocalOnlyLibs(classpath)
}
// 정적 리소스만 별도 zip (OHS 추가 배포용). war/bootWar 는 그대로 정적 리소스 포함 유지
// (WAS 단독 접속 인원 지원). sass/ 는 컴파일 소스이며 실제 서빙 경로에 없어 제외
// (PortalConfigWebDispatcherServlet#addResourceHandlers 기준).
task staticResourcesZip(type: Zip) {
archiveFileName = "eapim-portal-static.zip"
destinationDirectory = file("$buildDir/libs")
from('src/main/resources/static') {
exclude 'sass/**'
}
}
assemble.dependsOn staticResourcesZip
task printSourceSets {
doLast {
sourceSets.each { srcSet ->
+92
View File
@@ -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>
```
+343
View File
@@ -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())
+21
View File
@@ -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}"
}
}
}
+113
View File
@@ -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()
+542
View File
@@ -9,9 +9,454 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -322,6 +767,22 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -349,6 +810,48 @@
"node": ">=8"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -389,6 +892,16 @@
"license": "MIT",
"optional": true
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -403,6 +916,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+7 -2
View File
@@ -1,15 +1,20 @@
{
"name": "eapim-portal",
"version": "1.0.0",
"description": "SASS build system for EAPIM Portal",
"description": "SASS build system + forge custom bundle for EAPIM Portal",
"scripts": {
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
"build": "npm run sass:build && npm run sass:build:minified",
"dev": "npm run sass:watch"
"dev": "npm run sass:watch",
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
},
"author": "",
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
@@ -17,6 +18,7 @@ import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
@@ -37,6 +39,7 @@ public class ApiController {
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private final ApiListProperties apiListProperties;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -88,7 +91,8 @@ public class ApiController {
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services"));
@@ -109,8 +113,8 @@ public class ApiController {
*/
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
int total = apis == null ? 0 : apis.size();
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
int fromIndex = (int) Math.min(pageable.getOffset(), total);
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.apps.apis.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/** OPEN API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 공통 페이지 크기. */
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiListProperties {
private static final String PAGE_SIZE_PROPERTY = "api.list.page-size";
private static final int DEFAULT_PAGE_SIZE = 15;
private final PortalPropertyService portalPropertyService;
/** PTL_PROPERTY(Portal / api.list.page-size)를 조회하고, 없으면 기본값으로 생성한다. */
public int getPageSize() {
String value = portalPropertyService.getOrCreateProperty(
"Portal", PAGE_SIZE_PROPERTY, String.valueOf(DEFAULT_PAGE_SIZE),
"API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 페이지당 노출 개수 (양의 정수, 기본 15)");
try {
int size = Integer.parseInt(value == null ? "" : value.trim());
if (size > 0) {
return size;
}
} catch (NumberFormatException ignored) {
// 비어 있거나 정수가 아닌 설정은 기본값을 사용한다.
}
log.warn("{} 값이 올바르지 않음('{}') - 기본값 {} 사용", PAGE_SIZE_PROPERTY, value, DEFAULT_PAGE_SIZE);
return DEFAULT_PAGE_SIZE;
}
}
@@ -1,18 +1,28 @@
package com.eactive.apim.portal.apps.app.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
/**
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
*
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
*
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
*/
@Slf4j
@Service
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
private static final String PROP_GROUP = "Portal";
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
private static final String PROP_ADMIN_BASE_URL_DESC =
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
private final RestTemplate restTemplate;
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
/**
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
*
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
*
* @param clientId 차단할 클라이언트 ID
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
*/
public void blockClient(String clientId) {
String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
List<String> baseUrls = resolveBaseUrls();
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
RestClientException lastError = null;
for (int i = 0; i < baseUrls.size(); i++) {
String baseUrl = baseUrls.get(i);
String url = baseUrl + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
ResponseEntity<Map> response;
try {
response = restTemplate.postForEntity(url, null, Map.class, clientId);
} catch (RestClientException e) {
// 통신 계층 실패 — 다음 admin 주소로 failover
lastError = e;
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
continue;
}
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException(
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
}
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
return;
}
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
throw new IllegalStateException(
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
}
/**
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
*
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
*/
private List<String> resolveBaseUrls() {
String raw = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
List<String> urls = parseBaseUrls(raw);
if (urls.isEmpty()) {
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
}
return urls;
}
/**
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
*/
static List<String> parseBaseUrls(String raw) {
Set<String> ordered = new LinkedHashSet<>();
if (raw != null) {
for (String token : raw.split("[,\\r\\n]")) {
String url = stripTrailingSlashes(token.trim());
if (!url.isEmpty()) {
ordered.add(url);
}
}
}
return new ArrayList<>(ordered);
}
/**
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
LocalDate today = LocalDate.now();
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
searchDto.setStartDate(LocalDate.now().minusDays(7));
searchDto.setEndDate(LocalDate.now());
searchDto.setStartDate(today.minusDays(7));
searchDto.setEndDate(today);
searchDto.setClientId(null);
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
model.addAttribute("details", result.getDetails());
model.addAttribute("periods", result.getPeriods());
model.addAttribute("searchDto", searchDto);
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
model.addAttribute("statsMinDate", today.minusYears(1).toString());
model.addAttribute("statsMaxDate", today.toString());
// 월별 선택 가능 월 + 집계 안내 문구 데이터
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
return searchDto.isMonthly()
? "조회할 월이 올바르지 않습니다."
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
+ "일(시작일·종료일 포함)로 선택해주세요.";
}
private PortalOrg getPortalOrg() {
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
public class ApiStatisticsSearchDto {
/**
* 최대 조회 가능 일수 (일별 모드).
* 한 번에 조회 가능 일수 (일별 모드, 시작일·종료일 포함).
*/
public static final int MAX_DATE_RANGE_DAYS = 40;
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
}
/**
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
*/
public boolean isValidDateRange() {
return isValidDateRange(LocalDate.now());
}
boolean isValidDateRange(LocalDate today) {
if (startDate == null || endDate == null) {
return false;
}
if (startDate.isAfter(endDate)) {
return false;
}
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
return daysBetween <= MAX_DATE_RANGE_DAYS;
LocalDate earliestDate = today.minusYears(1);
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
}
/**
@@ -10,7 +10,6 @@ import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
@@ -22,21 +21,15 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -64,7 +57,6 @@ public class UserRegisterController {
private final AgreementsFacade agreementsFacade;
private final PortalProperties portalProperties;
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final ApprovalService approvalService;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
@Override
@Transactional
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendActivationEmail(newUser);
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
}
private void sendActivationEmail(PortalUser newUser) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(newUser.getUserName());
recipient.setUserId(newUser.getEmailAddr());
recipient.setPhone(newUser.getMobileNumber());
HashMap<String, Object> params = new HashMap<>();
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
try {
String encToken = encryptionUtil.encrypt(tokenValue);
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new SystemException("암호화 모듈 오류");
}
}
// 기존 사용자를 법인 사용자로 전환하는 메서드
private ValidationResponse convertExistingUserToCorporate(
PortalUser existingUser,
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
import com.eactive.apim.portal.agreements.entity.AgreementType;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Optional;
@Service
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
private final PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
private final AuthNumberGenerator authNumberGenerator;
@Override
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
}
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return false;
}
private void sendEmailActivation(PortalUser registeredUser) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(registeredUser.getUserName());
recipient.setUserId(registeredUser.getEmailAddr());
recipient.setPhone(registeredUser.getMobileNumber());
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
HashMap<String, Object> params = new HashMap<>();
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
params.put("token", tokenValue);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
// try {
// String encToken = encryptionUtil.encrypt(tokenValue);
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
// throw new SystemException("암호화 모듈 오류");
// }
}
@Override
@Transactional
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.template.entity.MessageCode;
import com.eactive.apim.portal.template.entity.MessageRequest;
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@@ -130,13 +130,22 @@ public class GlobalControllerAdvice {
}
/**
* 비밀번호 전송암호화 사용 여부(yml portal.security.password-encrypt.enabled).
* head 의 window.__PASSWORD_CRYPTO__ 로 내려가 password-crypto.js 가 읽는다.
* 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 화면은 평문으로 폴백한다.
* 비밀번호 전송암호화 설정(yml {@code portal.security.password-encrypt.*}).
* head 의 {@code window.__PASSWORD_CRYPTO__} 로 내려가 password-crypto.js 가 읽는다.
*
* <ul>
* <li>{@code enabled} - 꺼져 있으면 화면은 아무것도 하지 않고 평문 전송한다.</li>
* <li>{@code policy} - 평문 비밀번호 처리 정책. 화면은 경고 팝업 노출 여부·문구를 여기서 정한다.</li>
* <li>{@code softwareFallback} - {@code crypto.subtle} 이 없을 때 forge 번들을 내려받아 쓸지.</li>
* </ul>
*/
@ModelAttribute("passwordCryptoEnabled")
public boolean passwordCryptoEnabled() {
return passwordCryptoProperties.isEnabled();
@ModelAttribute("passwordCrypto")
public Map<String, Object> passwordCrypto() {
Map<String, Object> config = new java.util.LinkedHashMap<>();
config.put("enabled", passwordCryptoProperties.isEnabled());
config.put("policy", passwordCryptoProperties.getPlaintextPolicy().name());
config.put("softwareFallback", passwordCryptoProperties.isSoftwareFallback());
return config;
}
/**
@@ -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);
}
}
@@ -18,9 +18,17 @@ import java.util.Set;
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
*
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 1})보다 <b>앞</b>에서 이 래퍼가 씌워지므로,
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 비밀번호의
* 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 3})보다 <b>앞</b>({@code MIN_VALUE + 2})에서 이 래퍼가
* 씌워지므로, 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든
* 비밀번호의 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
*
* <p><b>멀티파트(파일 업로드) 요청 주의</b> — {@code getParameter()} 는 {@code super.getParameter()} 위임
* 결과를 복호화하는 방식이라, 이 래퍼가 감싸는 시점에 {@code super} 가 이미 멀티파트 파싱이 끝난
* {@code MultipartHttpServletRequest} 여야만 폼 필드(파일이 아닌 text part)를 볼 수 있다 — Spring 의
* 멀티파트 파라미터 맵은 자신이 파싱한 값을 곧바로 반환하고 감싸고 있는 원본 요청으로 위임하지 않기
* 때문이다. 그래서 {@link PasswordDecryptFilter} 는 MultipartFilter({@code order = MIN_VALUE + 1})
* "이후"에 실행되도록 등록돼 있고, 감싸는 대상이 {@code MultipartHttpServletRequest} 면 이 클래스 대신
* {@link DecryptingMultipartRequestWrapper}(이 클래스를 상속하며 멀티파트 인터페이스도 구현)를 쓴다.</p>
*/
@Slf4j
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
@@ -37,6 +45,13 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
private Map<String, String[]> parameterMapCache;
/**
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
*/
private boolean plaintextWarned;
public DecryptingRequestWrapper(HttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
@@ -97,13 +112,7 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
return value;
}
if (!codec.isEnvelope(value)) {
// strict 모드에서는 평문 비밀번호를 받아들이지 않는다. 예외를 던지면 로그인 경로가 500 이 되므로
// 빈 값으로 바꿔 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.
if (properties.isStrict() && PasswordParamNames.isPasswordLike(name)) {
log.warn("암호화되지 않은 비밀번호 파라미터 거부(strict) - uri={}, param={}", getRequestURI(), name);
return "";
}
return value;
return handlePlaintext(name, value);
}
String cached = decrypted.get(value);
@@ -137,6 +146,30 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
}
}
/**
* 봉투가 씌워지지 않은 파라미터 처리. 비밀번호 계열이 아니면 그대로 통과시킨다.
*
* <p>{@code ENFORCE} 에서 예외를 던지면 로그인 경로가 500 이 되므로 빈 값으로 바꿔
* 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.</p>
*/
private String handlePlaintext(String name, String value) {
PasswordCryptoProperties.PlaintextPolicy policy = properties.getPlaintextPolicy();
if (policy == PasswordCryptoProperties.PlaintextPolicy.NONE
|| !PasswordParamNames.isPasswordLike(name)) {
return value;
}
boolean reject = policy == PasswordCryptoProperties.PlaintextPolicy.ENFORCE;
if (!plaintextWarned) {
plaintextWarned = true;
log.warn("암호화되지 않은 비밀번호 파라미터 {} - policy={}, transport={}, uri={}, param={}",
reject ? "거부" : "허용", policy,
RequestTransport.isSecure((HttpServletRequest) getRequest()) ? "https" : "http",
getRequestURI(), name);
}
return reject ? "" : value;
}
/**
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
@@ -7,12 +7,11 @@ import org.springframework.stereotype.Component;
/**
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
*
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.
* 로컬 개발환경은 HTTP(비 secure context)라 브라우저 {@code crypto.subtle} 을 쓸 수 없으므로
* {@link #enabled} 를 꺼둔다. 켜져 있어도 브라우저가 지원하지 못하면 클라이언트가 평문으로 폴백한다.</p>
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.</p>
*
* <p><b>이 기능은 XSS 방어 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있다.
* "전송 페이로드 평문 노출" 점검 지적에 대한 대응 범위로만 이해할 것.</p>
* <p><b>이 기능은 XSS 방어도, MITM 방어도 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있고,
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. "전송 페이로드 평문 노출" 점검 지적에
* 대한 대응 범위로만 이해할 것.</p>
*/
@Data
@Component
@@ -32,6 +31,24 @@ public class PasswordCryptoProperties {
REQUEST, SESSION, SERVER
}
/**
* 봉투(ENC1)가 적용되지 않은 <b>평문 비밀번호 파라미터</b>를 서버가 어떻게 다룰지.
* 전송 구간(HTTP/HTTPS) 자체를 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
*
* <ul>
* <li>{@code NONE} - 무동작. 평문을 그대로 받는다.</li>
* <li>{@code PERMISSIVE} - 평문을 받되 서버 로그에 경고를 남기고, 화면에도 경고 팝업을 띄운다.</li>
* <li>{@code ENFORCE} - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).</li>
* </ul>
*
* <p>{@code ENFORCE} 는 전송 구간이 HTTP 여도 안전하게 켤 수 있다. {@link #softwareFallback} 이 켜져 있으면
* {@code crypto.subtle} 을 못 쓰는 환경에서도 클라이언트가 봉투를 만들기 때문이다. 다만 JS 를 끈
* 브라우저는 로그인하지 못하므로 {@code PERMISSIVE} 로 운영해 경고 로그를 지켜본 뒤 승격한다.</p>
*/
public enum PlaintextPolicy {
NONE, PERMISSIVE, ENFORCE
}
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
private boolean enabled = false;
@@ -41,11 +58,18 @@ public class PasswordCryptoProperties {
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
private int keyTtlSeconds = 300;
/** 평문 비밀번호 파라미터 처리 정책. */
private PlaintextPolicy plaintextPolicy = PlaintextPolicy.NONE;
/**
* true 면 서버가 평문 비밀번호 파라미터를 거부한다(빈 값으로 치환 → 인증 실패).
* JS 비활성 사용자나 구형 브라우저가 로그인하지 못하게 되므로 안정화 후에만 켠다.
* {@code crypto.subtle} 을 쓸 수 없는 환경(원격 오리진 HTTP = 비 secure context)에서
* 순수 JS 구현(forge)으로 봉투를 만들지 여부.
*
* <p>forge 번들은 {@code crypto.subtle} 이 없을 때만 동적으로 내려가므로 HTTPS 운영 환경에서는
* 전송 바이트가 0 이다. 즉 이 값은 사실상 킬스위치다. 끄면 HTTP 개발환경은 평문으로 폴백한다
* ({@link #plaintextPolicy} 가 {@code ENFORCE} 면 로그인 불가).</p>
*/
private boolean strict = false;
private boolean softwareFallback = true;
/** RSA 키 길이. */
private int rsaKeySize = 2048;
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
@@ -13,16 +14,29 @@ import java.io.IOException;
/**
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
*
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE} 로 등록해
* Lucy XSS 필터({@code MIN_VALUE + 1})보다 먼저 실행된다. 자세한 이유는
* {@link DecryptingRequestWrapper} 주석 참고.</p>
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE + 2} 로 등록해
* MultipartFilter({@code MIN_VALUE + 1}) "이후", Lucy XSS 필터({@code MIN_VALUE + 3}) "이전"에
* 실행된다. 자세한 이유는 {@link DecryptingRequestWrapper} / {@link DecryptingMultipartRequestWrapper}
* 주석 참고 — 멀티파트(파일 업로드 동반) 요청은 MultipartFilter 가 만든
* {@code MultipartHttpServletRequest} 가 폼 필드를 자체 파싱해 반환하므로, 이 필터가 그보다
* 먼저 실행되면 비밀번호 파라미터가 복호화되지 않은 채로 컨트롤러까지 전달된다(법인가입처럼
* 파일 첨부와 비밀번호가 같은 폼에 있는 경우 재현).</p>
*
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
*
* <p><b>{@code /internal/**} 은 대상에서 제외한다.</b> playwright 전용 테스트 정리 API
* ({@code TestCleanupInternalController} 등)는 공유 토큰({@code X-Internal-Token}) + IP 허용목록으로
* 이미 인증되는 서버-to-서버 호출이라 브라우저 암호화 대상이 아니다({@code MenuAccessInterceptor} 가
* 같은 이유로 {@code /internal/**} 을 제외하는 것과 동일 패턴). 이 경로를 필터에 그대로 태우면, 도구가
* 보내는 평문 {@code password} 파라미터가 {@code plaintext-policy=ENFORCE} 설정에 걸려 빈 문자열로
* 치환되고 내부 API 는 "필수값 누락"으로 거부한다(예: {@code /internal/test-cleanup/password}).</p>
*/
@Component
public class PasswordDecryptFilter implements Filter {
private static final String INTERNAL_API_PREFIX = "/internal/";
private final PasswordCryptoProperties properties;
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
@@ -45,17 +59,23 @@ public class PasswordDecryptFilter implements Filter {
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (!"POST".equalsIgnoreCase(httpRequest.getMethod())) {
if (!"POST".equalsIgnoreCase(httpRequest.getMethod()) || isInternalApiRequest(httpRequest)) {
chain.doFilter(request, response);
return;
}
DecryptingRequestWrapper wrapper =
new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
DecryptingRequestWrapper wrapper = httpRequest instanceof MultipartHttpServletRequest
? new DecryptingMultipartRequestWrapper((MultipartHttpServletRequest) httpRequest, codec, keyStore, properties)
: new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
try {
chain.doFilter(wrapper, response);
} finally {
wrapper.consumeUsedKeys();
}
}
private static boolean isInternalApiRequest(HttpServletRequest request) {
String path = request.getRequestURI().substring(request.getContextPath().length());
return path.startsWith(INTERNAL_API_PREFIX);
}
}
@@ -1,7 +1,7 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 비밀번호 계열 파라미터명 판별. {@code strict} 모드에서 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
* 비밀번호 계열 파라미터명 판별. {@code plaintext-policy} "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
*
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
@@ -0,0 +1,31 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import javax.servlet.http.HttpServletRequest;
/**
* 요청이 TLS 구간으로 들어왔는지 판정한다. 경고 로그 문구를 가르는 용도다.
*
* <p>{@code request.isSecure()} 만 봐서는 안 된다. {@code PasswordDecryptFilter} 는 Lucy XSS 필터보다
* 먼저 돌아야 해서 order 가 {@code Integer.MIN_VALUE} 인데, {@code ForwardedHeaderFilter}
* ({@code PortalConfigForwardedHeader}) 도 {@code Ordered.HIGHEST_PRECEDENCE} = 같은 값이라
* 둘의 상대 순서가 보장되지 않는다. 즉 이 시점의 {@code isSecure()} 는 {@code X-Forwarded-Proto}
* 교정 <b>전</b> 값일 수 있다.</p>
*
* <p>그래서 헤더를 직접 본다. 앞단 프록시가 신뢰 경계 안이라는 전제는 {@code ForwardedHeaderFilter} 와 같다.</p>
*/
final class RequestTransport {
private RequestTransport() {
}
static boolean isSecure(HttpServletRequest request) {
String proto = request.getHeader("X-Forwarded-Proto");
if (proto != null && !proto.isEmpty()) {
// 프록시가 여러 단이면 "https, http" 처럼 쌓인다. 클라이언트에 가장 가까운 첫 값이 기준이다.
int comma = proto.indexOf(',');
String first = (comma >= 0 ? proto.substring(0, comma) : proto).trim();
return "https".equalsIgnoreCase(first);
}
return request.isSecure();
}
}
@@ -1,18 +1,5 @@
package com.eactive.apim.portal.common.util;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component("encryptionUtil")
public class EncryptionUtil {
public static String generateNewPassword() {
@@ -53,40 +40,4 @@ public class EncryptionUtil {
return newpassword.toString();
}
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
private static final String ALGORITHM = "AES";
private SecretKeySpec createSecretKey() {
byte[] key = secretKey.getBytes(StandardCharsets.UTF_8);
return new SecretKeySpec(key, ALGORITHM);
}
public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
if (value == null || value.isEmpty()) {
return value;
}
SecretKeySpec key = createSecretKey();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encryptedBytes = cipher.doFinal(value.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
}
public String decrypt(String encrypted) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
if (encrypted == null || encrypted.isEmpty()) {
return encrypted;
}
SecretKeySpec key = createSecretKey();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encrypted));
return new String(decryptedBytes);
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.eactive.apim.portal.common.security.passwordcrypto.DecryptingMultipartRequestWrapper;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
@@ -66,25 +67,40 @@ public class PortalConfigSecurity {
}
/**
* 비밀번호 전송암호화 복호화 필터. Lucy XSS 필터(order = MIN_VALUE + 1)보다 <b>먼저</b> 실행되어야 한다.
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 특수문자가 든 비밀번호의 해시 비교 결과가
* 지금과 동일하게 유지된다. 순서를 뒤집으면 기존 계정 로그인이 깨진다.
* 비밀번호 전송암호화 복호화 필터.
*
* <p>order {@code MIN_VALUE + 2} — {@code PortalConfigWebDispatcherServlet} 의
* MultipartFilter({@code MIN_VALUE + 1}) <b>이후</b>, 아래 Lucy XSS 필터
* ({@code MIN_VALUE + 3}) <b>이전</b>에 실행되어야 한다.</p>
*
* <p>MultipartFilter 이후여야 하는 이유: 파일 업로드가 동반된 폼(예: 법인가입의 사업자등록증
* 첨부)은 {@code multipart/form-data} 로 제출되는데, Spring 의 {@code MultipartHttpServletRequest}
* 는 폼 필드 값을 자체 파싱해 {@code getParameter()} 에서 곧바로 반환하고 감싸고 있는 원본 요청으로
* 위임하지 않는다. 이 필터가 MultipartFilter 보다 먼저 실행되어(즉 더 안쪽에서) 요청을 감싸면,
* 멀티파트 안의 비밀번호 파라미터는 이 필터의 복호화 로직을 거치지 않고 봉투(ENC1) 문자열 그대로
* 컨트롤러까지 전달된다({@link DecryptingMultipartRequestWrapper} 주석 참고).</p>
*
* <p>Lucy XSS 필터 이전이어야 하는 이유: 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야
* 특수문자가 든 비밀번호의 해시 비교 결과가 지금과 동일하게 유지된다. 이 순서를 뒤집으면 기존
* 계정 로그인이 깨진다.</p>
*/
@Bean
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
PasswordDecryptFilter filter) {
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
registrationBean.setOrder(Integer.MIN_VALUE);
registrationBean.setOrder(Integer.MIN_VALUE + 2);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
// order MIN_VALUE + 3 — 위 PasswordDecryptFilter(MIN_VALUE + 2) 다음에 실행되어야
// 복호화된 평문이 XSS 이스케이프를 거친다(순서를 뒤집으면 기존 계정 로그인이 깨짐).
@Bean
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
XssEscapeServletFilter xssEscapeServletFilter = new XssEscapeServletFilter();
registrationBean.setFilter(xssEscapeServletFilter);
registrationBean.setOrder(Integer.MIN_VALUE + 1);
registrationBean.setOrder(Integer.MIN_VALUE + 3);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
@@ -175,31 +175,44 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
*
* <p>버전닝이 꺼져 있으면(prod 포함 — {@link #isResourceVersioningEnabled()} 참고) URL 이
* 고정이라 장기 {@code max-age} 캐시를 쓰면 배포 후에도 브라우저가 옛 내용을 계속 쓸 위험이 있다.
* 이 경우 {@code no-cache}(매 요청 {@code If-Modified-Since} 조건부 재검증, 변경 없으면 304)로
* 신선도를 보장한다 — OHS 가 정적 리소스를 직접 서빙해도 Apache 가 파일 mtime 기준으로 동일하게
* Last-Modified/조건부 GET 을 처리하므로 WAS 와 동작이 어긋나지 않는다
* (배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리).</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
CacheControl cacheControl = isResourceCachingEnabled()
? CacheControl.maxAge(1, TimeUnit.DAYS)
: CacheControl.noStore();
boolean versioningOn = isResourceVersioningEnabled();
CacheControl cacheControl;
if (!isResourceCachingEnabled()) {
// no-store: 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다(dev/local).
cacheControl = CacheControl.noStore();
} else if (versioningOn) {
cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS);
} else {
cacheControl = CacheControl.noCache();
}
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(cacheControl)
.resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
if (versioningOn) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
chain.addResolver(new PathResourceResolver());
}
/**
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
* 정적자원 해시 버전닝 활성 여부. {@code app.resource-versioning.enabled} 값을 그대로 따른다.
*
* <p>과거 prod 프로파일은 이 토글과 무관하게 항상 ON 이었으나, OHS 가 정적 리소스를 직접
* 서빙하는 구조로 바뀌면서 OFF 로 전환했다 — 해시가 붙은 URL(main-&lt;hash&gt;.css)이 OHS
* 디스크의 무해시 원본과 어긋나 404 가 날 수 있어서다(prod.yml 에서 명시적으로 false 설정).
* 대신 캐시 신선도는 {@link #addStaticResourceHandler} 의 no-cache 조건부 재검증으로 보장한다.</p>
*/
private boolean isResourceVersioningEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceVersioningEnabled;
}
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 연결 타임아웃: 10초
factory.setConnectTimeout(10000);
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
factory.setConnectTimeout(5000);
// 읽기 타임아웃: 30
factory.setReadTimeout(30000);
// 읽기 타임아웃: 5
factory.setReadTimeout(5000);
return new RestTemplate(factory);
}
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Controller
@RequestMapping("//notitest")
@RequestMapping("/djb/notitest")
@RequiredArgsConstructor
public class NotiTestController {
+4 -2
View File
@@ -41,9 +41,11 @@ gateway:
portal:
# auth-virtual-code: 654321
security:
# 개발 서버는 HTTPS 가 아니라 브라우저 Web Crypto 를 쓸 수 없다.
# 개발 서버는 HTTP(비 secure context)라 crypto.subtle 이 없다. software-fallback(forge)으로 암호화한다.
password-encrypt:
enabled: false
enabled: true
# 개발 서버는 원격 오리진 HTTP → crypto.subtle 이 없다. 기본값인 software-fallback(forge)으로 암호화된다.
plaintext-policy: permissive
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+11 -6
View File
@@ -9,9 +9,11 @@ spring:
cachecontrol:
max-age: 86400
public: true
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
# 정적자원 콘텐츠 해시 버전닝: OHS 가 정적 리소스(css/js/img 등)를 직접 서빙하는 구조에서
# 해시 URL(main-<hash>.css)이 OHS 디스크의 무해시 원본과 어긋나 404 위험이 있어 OFF 로 전환했다
# (아래 app.resource-versioning.enabled: false, PortalConfigWebDispatcherServlet 참고).
# 캐시 무효화는 대신 no-cache(매 요청 If-Modified-Since 조건부 재검증)로 보장한다.
# 배경: djb-obsidian-docs/3000-테스트/15-Static리소스분리
jpa:
properties:
hibernate:
@@ -28,7 +30,7 @@ spring:
app:
resource-versioning:
enabled: true
enabled: false
resource-caching:
enabled: true
@@ -41,7 +43,10 @@ server:
portal:
security:
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
# strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가).
password-encrypt:
enabled: true
strict: false
# 운영은 평문 비밀번호를 받지 않는다. 봉투가 아니면 빈 값으로 치환돼 인증 실패로 떨어진다.
# 대가: JS 를 끈 브라우저는 로그인할 수 없고, 공개키 발급(/api/security/password-key.json)이
# 죽으면 로그인 전체가 막힌다(permissive 면 평문으로 degrade 되어 로그인은 됐을 상황).
# 되돌리려면 이 값을 permissive 로 바꾸고 재기동해야 한다 — yml 이라 무중단 토글은 안 된다.
plaintext-policy: enforce
+2 -1
View File
@@ -23,9 +23,10 @@ portal:
# 검증 단계에선 사용하지 않음
# auth-virtual-code: 654321
security:
# HTTPS 구간이라 전송암호화를 켠다. strict 는 운영 승격 전 검증 후 판단.
# HTTPS 구간이라 전송암호화를 켠다. plaintext-policy 승격(ENFORCE)은 운영 승격 전 검증 후 판단.
password-encrypt:
enabled: true
plaintext-policy: permissive
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+40 -5
View File
@@ -155,15 +155,50 @@ portal:
security:
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
# 로컬/개발 서버는 HTTP(비 secure context)라 브라우저 crypto.subtle 을 못 쓰므로 기본은 꺼둔다.
# 켜져 있어도 브라우저가 지원하지 못하면 화면이 평문으로 폴백한다.
#
# 화면(password-crypto.js)이 서버 공개키로 AES 키를 감싸고 비밀번호를 그 AES 키로 암호화해
# ENC1.<keyId>.<랩된AES키>.<iv>.<암호문+태그> 봉투로 보낸다. 서버는 PasswordDecryptFilter 가
# 파라미터를 평문으로 되돌리므로 컨트롤러는 이 기능을 몰라도 된다.
#
# 주의: XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 털리고,
# HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다.
#
# 아래는 모든 키의 기본값이다. 프로파일 yml 에는 이 값과 다른 것만 적는다.
password-encrypt:
# 마스터 스위치. false 면 필터·키 발급 엔드포인트가 모두 무동작이고 화면은 평문 전송한다.
# 기본은 꺼둔다 — 켜는 것은 프로파일 yml 의 판단.
enabled: false
# REQUEST(폼당 1회용) | SESSION(세션당) | SERVER(서버 고정 + TTL 로테이션)
# RSA 개인키 보관 범위.
# REQUEST - 폼 진입마다 1회용 키 발급, 요청 1회 사용 후 폐기. 재전송 차단이 가장 강하다.
# SESSION - 세션 단위 보관. 세션 복제 환경에 유리.
# SERVER - 서버 고정 키쌍 + TTL 로테이션. 무상태라 단순하지만 재전송 방지 수단이 없다.
key-scope: REQUEST
# 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기.
key-ttl-seconds: 300
# true 면 평문 비밀번호 파라미터를 거부한다. JS 비활성 사용자가 로그인하지 못하므로 안정화 후에만 켠다.
strict: false
# 봉투가 씌워지지 않은 '평문 비밀번호 파라미터' 를 서버가 어떻게 다룰지.
# 전송 구간(HTTP/HTTPS)을 막는 스위치가 아니다 — 전송 구간은 경고 문구에만 영향을 준다.
# none - 무동작. 평문을 그대로 받는다.
# permissive - 평문을 받되 서버 로그에 경고를 남기고(요청당 1줄), 화면에도 경고 팝업을 띄운다.
# HTTP 접속이면 비밀번호 입력 화면에서 세션당 1회 팝업이 뜬다.
# enforce - 평문 비밀번호를 거부한다(빈 값 치환 → 인증 실패).
# JS 를 끈 브라우저는 로그인하지 못하므로, permissive 로 운영하며 위 경고 로그가
# 안 나오는 것을 확인한 뒤에 승격한다.
# 기본은 none — 어느 환경에 올려도 기존 동작이 바뀌지 않게 한다.
# (enabled: false 면 필터가 통째로 무동작이라 이 값은 어차피 영향이 없다.)
plaintext-policy: none
# crypto.subtle 을 쓸 수 없는 환경에서 순수 JS 구현(forge)으로 봉투를 만들지 여부.
# 브라우저는 secure context(HTTPS·localhost)가 아니면 crypto.subtle 을 아예 노출하지 않는다.
# 즉 원격 오리진 HTTP(사내 IP 접속 등)에서는 이 값을 켜야 암호화가 걸린다.
# forge 번들(js/lib/forge-crypto.min.js, gzip 약 31KB)은 crypto.subtle 이 없을 때만 동적으로
# 내려가므로 HTTPS 구간에서는 전송 바이트가 0 이다. 사실상 킬스위치.
# 기본은 true — HTTPS 구간에서는 어차피 내려가지 않고, HTTP 구간에서는 켜져 있어야 의미가 있다.
software-fallback: true
# RSA 키 길이.
rsa-key-size: 2048
pages:
+76 -53
View File
@@ -1247,7 +1247,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -2574,7 +2574,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
transform: translateY(-3px);
}
.btn-danger {
@@ -2582,7 +2582,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2848,7 +2848,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2966,7 +2966,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -3041,7 +3041,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px);
color: #fff;
}
@@ -3091,7 +3091,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3131,7 +3131,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.badge-sm {
@@ -4319,7 +4319,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4640,7 +4640,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4795,7 +4795,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
.form-row--content .form-label-wrapper {
@@ -5067,6 +5067,8 @@ select.form-control {
color: #212529;
font-size: 20px;
line-height: 1.6;
word-break: keep-all;
overflow-wrap: break-word;
}
.modal-body p {
margin: 0;
@@ -5733,7 +5735,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6447,7 +6449,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6455,7 +6457,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
background-color: rgb(216.7625, 224.8125, 244.9375);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6463,7 +6465,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6471,7 +6473,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
}
.table-pagination {
@@ -7121,7 +7123,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
color: rgb(255, 70.8, 70.8);
align-items: center;
}
.alert.alert-error svg {
@@ -7135,7 +7137,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11549,10 +11551,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
background: rgb(25.65, 70.3, 173.85);
}
.login-button:active {
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
background: rgb(24.3, 66.6, 164.7);
}
.login-button:disabled {
opacity: 0.6;
@@ -11595,10 +11597,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
background: rgb(220.61, 227.85, 245.95);
}
.login-links-container .link-btn:active {
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
background: rgb(205.22, 215.7, 241.9);
}
.login-alert {
@@ -12107,12 +12109,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
background: rgb(21.411588785, 146.3125233645, 233.148411215);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12161,10 +12163,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
background: rgb(225.45, 229.39, 235.3);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
background: rgb(210.9, 216.78, 225.6);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12174,7 +12176,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12410,7 +12412,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17610,7 +17612,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17637,7 +17639,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
.btn-view-secret svg {
width: 20px;
@@ -17822,7 +17824,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
.btn-view-secret {
width: 100% !important;
@@ -17838,7 +17840,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
#revealedSecretBox {
width: 100%;
@@ -18111,7 +18113,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(74.3529411765%, 90.2296918768%, 100%);
background: rgb(189.6, 230.0857142857, 255);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18292,7 +18294,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18310,7 +18312,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(100%, 27.4868759774%, 25.1921568627%);
background: rgb(255, 70.0915337423, 64.24);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19884,7 +19886,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19917,7 +19919,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19950,7 +19952,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -20022,7 +20024,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -20054,7 +20056,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20114,7 +20116,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20122,7 +20124,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20911,7 +20913,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21447,7 +21449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
}
.org-file-notice {
@@ -22488,7 +22490,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
@@ -25273,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #666;
}
.api-statistics-container .statistics-date-range {
width: 260px;
max-width: 100%;
cursor: pointer;
}
.api-statistics-container .statistics-date-range:focus-visible {
outline: 2px solid #0049B4;
outline-offset: 2px;
border-radius: 6px;
}
.daterangepicker.statistics-date-picker {
z-index: 1100;
max-width: calc(100vw - 16px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.daterangepicker.statistics-date-picker .applyBtn {
color: #fff;
background: #0049B4;
border-radius: 4px;
}
@media (max-width: 560px) {
.daterangepicker.statistics-date-picker {
max-height: 70vh;
overflow-y: auto;
}
}
.btn-search {
background: #0049B4;
border: none;
@@ -27323,9 +27353,6 @@ input[type=checkbox]:checked + .custom-checkbox {
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.api-status .as-maint-card.is-ongoing {
border-left-color: var(--as-warn);
}
.api-status .as-maint-card .as-maint-head {
display: flex;
align-items: baseline;
@@ -27337,7 +27364,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--as-text);
color: var(--as-info);
}
.api-status .as-maint-card .as-schedule {
display: inline-flex;
@@ -27351,10 +27378,6 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: var(--as-pill);
font-variant-numeric: tabular-nums;
}
.api-status .as-maint-card.is-ongoing .as-schedule {
color: var(--as-warn);
background: var(--as-warn-bg);
}
.api-status .as-maint-card .as-maint-body {
margin: 14px 0 12px;
font-size: 14px;
File diff suppressed because one or more lines are too long
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

+3 -2
View File
@@ -8,7 +8,7 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
const configuredPageSize = Number(globalThis.API_SELECTOR_PAGE_SIZE);
const PAGE_SIZE = Number.isInteger(configuredPageSize) && configuredPageSize > 0 ? configuredPageSize : 15;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
Copyright (c) JS Foundation and other contributors
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because one or more lines are too long
+410 -56
View File
@@ -8,11 +8,18 @@
*
* 서버(PasswordDecryptFilter)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
*
* 폴백: 설정이 꺼져 있거나(window.__PASSWORD_CRYPTO__.enabled=false), secure context 가 아니거나
* (로컬 HTTP 개발환경), crypto.subtle 이 없으면 아무 일도 하지 않고 평문 그대로 전송한다.
* 키 조회·암호화 중 오류가 나도 마찬가지다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다.
* 암호화 엔진은 두 가지이며 봉투 형식은 동일하다.
* - subtle : secure context(HTTPS·localhost)에서 브라우저 Web Crypto 사용. 추가 다운로드 없음.
* - forge : 원격 오리진 HTTP 처럼 crypto.subtle 이 없는 환경에서 순수 JS 구현 사용.
* lib/forge-crypto.min.js 를 그때만 동적으로 내려받는다
* (window.__PASSWORD_CRYPTO__.softwareFallback 로 끌 수 있다).
*
* 주의: 이 조치는 XSS 방어가 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있다.
* 폴백: 설정이 꺼져 있거나, 두 엔진 모두 쓸 수 없거나, 키 조회·암호화 중 오류가 나면 아무 일도 하지 않고
* 평문 그대로 전송한다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다. 서버 정책이 ENFORCE 면 그 평문이
* 거부되어 인증 실패로 이어진다.
*
* 주의: 이 조치는 XSS 방어도 MITM 방어도 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있고,
* HTTP 구간이면 중간자가 이 스크립트 자체를 바꿔치기할 수 있다. 그래서 HTTP 접속에는 경고 팝업을 띄운다.
*/
(function (global) {
'use strict';
@@ -20,26 +27,52 @@
var cfg = global.__PASSWORD_CRYPTO__ || {};
var PREFIX = 'ENC1.';
/** 봉투를 만들 수 있는 환경에서 전송로 경고를 세션당 한 번만 띄우기 위한 sessionStorage 키 */
var WARN_KEY = 'portal.passwordCrypto.insecureWarned';
// ---------- 엔진 판정 ----------
/**
* Web Crypto 사용 가능 여부. 브라우저는 secure context 가 아니면 crypto.subtle 자체를 노출하지 않는다.
* 반면 crypto.getRandomValues 는 비 secure context 에서도 쓸 수 있어 forge 경로의 난수원으로 쓴다.
*/
function hasSubtle() {
return !!(global.isSecureContext && global.crypto && global.crypto.subtle);
}
function hasForge() {
return !!(cfg.softwareFallback && cfg.forgeUrl && global.document);
}
/** 설정·브라우저 조건상 봉투를 만들 수 있는가. 실제 forge 스크립트 로드는 사용 시점에 한다. */
function available() {
return !!(cfg.enabled
&& global.isSecureContext
&& cfg.keyUrl
&& global.crypto
&& global.crypto.subtle
&& global.crypto.getRandomValues
&& global.fetch
&& global.Promise);
&& global.Promise
&& (hasSubtle() || hasForge()));
}
// ---------- 인코딩 유틸 ----------
function toBase64Url(buffer) {
var bytes = new Uint8Array(buffer);
function bytesToBinary(bytes) {
var binary = '';
for (var i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
}
function binaryToBase64Url(binary) {
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function toBase64Url(buffer) {
return binaryToBase64Url(bytesToBinary(new Uint8Array(buffer)));
}
function base64ToBytes(base64) {
var binary = global.atob(base64);
var bytes = new Uint8Array(binary.length);
@@ -49,19 +82,161 @@
return bytes;
}
/** forge 는 바이트를 binary string 으로 다룬다. 난수는 항상 브라우저 CSPRNG 에서 받는다. */
function randomBinary(length) {
return bytesToBinary(global.crypto.getRandomValues(new Uint8Array(length)));
}
// ---------- 엔진 구현 ----------
/**
* 엔진 계약:
* importKey(base64Spki) -> Promise<publicKey>
* seal(publicKey, keyId, names, values) -> Promise<{이름: 봉투}>
* 두 엔진의 봉투 형식·알고리즘 파라미터는 동일해야 한다. 서버(PasswordEnvelopeCodec)는
* RSA-OAEP 의 MGF1 해시까지 SHA-256 으로 고정돼 있으므로 forge 쪽도 mgf1 을 명시해야 한다.
*/
var subtleEngine = {
name: 'subtle',
importKey: function (base64Spki) {
return global.crypto.subtle.importKey(
'spki',
base64ToBytes(base64Spki),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
},
seal: function (publicKey, keyId, names, values) {
var subtle = global.crypto.subtle;
var context = {};
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt'])
.then(function (aesKey) {
context.aesKey = aesKey;
return subtle.exportKey('raw', aesKey);
})
.then(function (rawAesKey) {
return subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, rawAesKey);
})
.then(function (wrapped) {
var wrappedKey = toBase64Url(wrapped);
return global.Promise.all(names.map(function (name) {
var iv = global.crypto.getRandomValues(new Uint8Array(12));
return subtle.encrypt(
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
context.aesKey,
new TextEncoder().encode(values[name])
).then(function (cipherText) {
return {
name: name,
envelope: PREFIX + keyId + '.' + wrappedKey
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
};
});
}));
})
.then(collectEnvelopes);
}
};
var forgeEngine = {
name: 'forge',
importKey: function (base64Spki) {
return loadForge().then(function (forge) {
var der = forge.util.createBuffer(global.atob(base64Spki));
return forge.pki.publicKeyFromAsn1(forge.asn1.fromDer(der));
});
},
seal: function (publicKey, keyId, names, values) {
return loadForge().then(function (forge) {
var aesKey = randomBinary(32);
// forge 의 OAEP 시드도 브라우저 CSPRNG 로 준다. forge 내장 PRNG 를 쓰지 않기 위함이다.
var wrappedKey = binaryToBase64Url(publicKey.encrypt(aesKey, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
seed: randomBinary(32)
}));
return names.map(function (name) {
var iv = randomBinary(12);
var cipher = forge.cipher.createCipher('AES-GCM', aesKey);
cipher.start({ iv: iv, tagLength: 128 });
cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(values[name])));
cipher.finish();
// 서버는 Java 관례대로 "암호문 + 태그" 가 이어 붙은 형태를 기대한다.
var payload = cipher.output.getBytes() + cipher.mode.tag.getBytes();
return {
name: name,
envelope: PREFIX + keyId + '.' + wrappedKey
+ '.' + binaryToBase64Url(iv) + '.' + binaryToBase64Url(payload)
};
});
}).then(collectEnvelopes);
}
};
function collectEnvelopes(results) {
var out = {};
results.forEach(function (result) {
out[result.name] = result.envelope;
});
return out;
}
/** 현재 환경에서 쓸 엔진. 페이지 수명 동안 바뀌지 않는다. */
function engine() {
if (hasSubtle()) {
return subtleEngine;
}
return hasForge() ? forgeEngine : null;
}
// ---------- forge 동적 로드 ----------
var forgeLoading = null;
/**
* forge 번들은 crypto.subtle 이 없을 때만 내려받는다. HTTPS 운영 환경에서는 전송 바이트가 0 이다.
* 한 번 시작한 로드는 재사용하고, 실패하면 호출자가 평문 폴백으로 처리한다.
*/
function loadForge() {
if (global.forge && global.forge.pki && global.forge.cipher) {
return global.Promise.resolve(global.forge);
}
if (forgeLoading) {
return forgeLoading;
}
forgeLoading = new global.Promise(function (resolve, reject) {
var script = global.document.createElement('script');
script.src = cfg.forgeUrl;
script.async = true;
script.onload = function () {
if (global.forge && global.forge.pki && global.forge.cipher) {
resolve(global.forge);
} else {
reject(new Error('forge 번들이 전역을 노출하지 않음'));
}
};
script.onerror = function () {
reject(new Error('forge 번들 로드 실패: ' + cfg.forgeUrl));
};
global.document.head.appendChild(script);
});
return forgeLoading;
}
// ---------- 키 조회 ----------
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
var cachedKey = null;
/**
* 서버에서 공개키를 받아 import 한다.
* 서버에서 공개키를 받아 현재 엔진 형식으로 import 한다.
*
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
* 생성하게 만들지 않기 위함이다.
*/
function loadKey() {
function loadKey(currentEngine) {
if (cachedKey && cachedKey.expiresAt > Date.now()) {
return global.Promise.resolve(cachedKey);
}
@@ -79,13 +254,7 @@
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
throw new Error('전송암호화 비활성 상태의 응답');
}
return global.crypto.subtle.importKey(
'spki',
base64ToBytes(data.publicKey),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
).then(function (publicKey) {
return currentEngine.importKey(data.publicKey).then(function (publicKey) {
var key = { keyId: data.keyId, publicKey: publicKey };
if (data.keyScope && data.keyScope !== 'REQUEST') {
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
@@ -112,45 +281,20 @@
var v = values[name];
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
});
if (!available() || names.length === 0) {
var currentEngine = available() ? engine() : null;
if (!currentEngine || names.length === 0) {
return global.Promise.resolve(values || {});
}
var subtle = global.crypto.subtle;
var context = {};
return loadKey().then(function (key) {
context.keyId = key.keyId;
context.publicKey = key.publicKey;
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
}).then(function (aesKey) {
context.aesKey = aesKey;
return subtle.exportKey('raw', aesKey);
}).then(function (rawAesKey) {
return subtle.encrypt({ name: 'RSA-OAEP' }, context.publicKey, rawAesKey);
}).then(function (wrapped) {
context.wrappedKey = toBase64Url(wrapped);
return global.Promise.all(names.map(function (name) {
var iv = global.crypto.getRandomValues(new Uint8Array(12));
return subtle.encrypt(
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
context.aesKey,
new TextEncoder().encode(values[name])
).then(function (cipherText) {
return {
name: name,
envelope: PREFIX + context.keyId + '.' + context.wrappedKey
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
};
});
}));
}).then(function (results) {
return loadKey(currentEngine).then(function (key) {
return currentEngine.seal(key.publicKey, key.keyId, names, values);
}).then(function (envelopes) {
var out = {};
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
results.forEach(function (result) { out[result.name] = result.envelope; });
Object.keys(envelopes).forEach(function (name) { out[name] = envelopes[name]; });
return out;
}).catch(function (error) {
// 암호화 실패는 화면을 막지 않는다. 서버 strict 가 꺼져 있으면 평문으로 처리된다.
// 암호화 실패는 화면을 막지 않는다. 서버 정책이 ENFORCE 가 아니면 평문으로 처리된다.
if (global.console && global.console.warn) {
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
}
@@ -158,8 +302,86 @@
});
}
/** 원본 입력에서 name 을 잠시 떼어둔 동안 그 이름을 보관하는 표시용 속성 */
var NAME_HOLDER = 'data-password-crypto-name';
/**
* 폼의 지정 필드 값을 봉투로 치환한다. 실패해도 reject 하지 않고 평문을 남긴다.
* 입력 요소에서 실제 <input> 하나를 고른다.
* 같은 name 이 둘 이상이면 form.elements[name] 이 RadioNodeList 를 돌려주므로,
* 우리가 붙인 hidden 이 아닌 실제 입력창을 골라야 한다.
*/
function resolveInput(el) {
if (!el) {
return null;
}
if (el.tagName) {
return el;
}
for (var i = 0; i < el.length; i++) {
var candidate = el[i];
if (candidate && candidate.tagName && candidate.type !== 'hidden') {
return candidate;
}
}
return null;
}
/**
* 원본 입력의 value 는 그대로 두고, name 만 같은 이름의 hidden 으로 옮겨 봉투를 싣는다.
* @returns {Function} 원상복구 함수
*/
function swapToEnvelopeField(form, input, name, envelope) {
var hidden = (form.ownerDocument || global.document).createElement('input');
hidden.type = 'hidden';
hidden.name = name;
hidden.value = envelope;
hidden.setAttribute(NAME_HOLDER, name);
input.setAttribute(NAME_HOLDER, name);
input.removeAttribute('name');
form.appendChild(hidden);
return function restore() {
if (hidden.parentNode) {
hidden.parentNode.removeChild(hidden);
}
if (input.getAttribute(NAME_HOLDER) === name) {
input.setAttribute('name', name);
input.removeAttribute(NAME_HOLDER);
}
};
}
/**
* 제출 직후 원상복구한다. 폼 데이터 직렬화는 form.submit()/requestSubmit() 호출 시점에
* 동기로 끝나므로, 다음 매크로태스크에서 되돌려도 봉투는 이미 전송된 뒤다.
* 뒤로가기(bfcache) 복원 시에도 name 이 떨어진 채 남지 않도록 pageshow 에서 한 번 더 처리한다.
*/
function scheduleRestore(restores) {
if (restores.length === 0) {
return;
}
var done = false;
var run = function () {
if (done) {
return;
}
done = true;
restores.forEach(function (restore) { restore(); });
global.removeEventListener('pageshow', run);
};
global.addEventListener('pageshow', run);
global.setTimeout(run, 0);
}
/**
* 폼의 지정 필드를 봉투로 전송한다. 실패해도 reject 하지 않고 평문을 남긴다.
*
* 입력창(<input type="password">)의 value 는 절대 건드리지 않는다. 브라우저 비밀번호 관리자는
* 제출 시점의 입력값을 그대로 저장하므로 봉투로 덮어쓰면 봉투 문자열이 비밀번호로 저장되고,
* 다음 자동완성 로그인이 깨진다. 대신 원본 입력의 name 을 잠시 떼고 같은 name 의 hidden 에
* 봉투를 실어 보낸 뒤 제출 직후 되돌린다.
*
* @param {HTMLFormElement} form
* @param {string[]} fieldNames
* @returns {Promise<void>}
@@ -171,7 +393,7 @@
var targets = {};
var elements = {};
fieldNames.forEach(function (name) {
var el = form.elements ? form.elements[name] : null;
var el = resolveInput(form.elements ? form.elements[name] : null);
if (el && typeof el.value === 'string' && el.value.length > 0) {
elements[name] = el;
targets[name] = el.value;
@@ -181,14 +403,136 @@
return global.Promise.resolve();
}
return encryptValues(targets).then(function (encrypted) {
var restores = [];
Object.keys(elements).forEach(function (name) {
if (encrypted[name]) {
elements[name].value = encrypted[name];
// 폴백으로 평문이 그대로 돌아온 경우엔 아무것도 바꾸지 않는다.
if (encrypted[name] && encrypted[name] !== targets[name]) {
restores.push(swapToEnvelopeField(form, elements[name], name, encrypted[name]));
}
});
scheduleRestore(restores);
});
}
// ---------- 전송로 경고 ----------
/**
* 팝업을 띄운다. custom-popups.js 는 `const customPopups`(전역 렉시컬)로 노출돼
* window 프로퍼티가 아니므로 식별자로 직접 확인한다. 팝업 프래그먼트가 없는 화면에서는
* 브라우저 기본 alert 로 떨어진다.
*/
function showDialog(message) {
try {
if (typeof customPopups !== 'undefined'
&& customPopups && typeof customPopups.showAlert === 'function'
&& global.document.getElementById('customAlert')) {
customPopups.showAlert(message);
return;
}
} catch (e) {
// customPopups 미정의 등 — 아래 기본 alert 로 떨어진다.
}
global.alert(message.replace(/<br\s*\/?>/gi, '\n'));
}
/** 알림 팝업이 지금 떠 있는가. custom-popups.js 는 #customAlert 를 show()/hide() 로 토글한다. */
function isAlertOpen() {
var el = global.document.getElementById('customAlert');
return !!(el && el.style.display !== 'none');
}
/**
* 이미 떠 있는 알림이 닫힌 뒤에 띄운다.
*
* 알림 팝업은 페이지 전체가 #customAlert 하나를 돌려쓰고 showAlert() 는 그 안의 메시지를 덮어쓴다.
* 우리 경고는 load 시점이라 화면 자신의 메시지(DOMContentLoaded 에서 뜨는 로그인 실패 안내 등)보다
* 늦게 실행되므로, 그냥 부르면 그 메시지를 지워버린다. 먼저 뜬 쪽을 존중하고 뒤에 선다.
*
* hideAlert() 가 300ms 애니메이션 뒤에 display 를 내리므로 폴링으로 확인한다.
* 사용자가 계속 닫지 않으면 1분 뒤 포기한다(경고를 못 봐도 화면을 방해하지는 않는다).
*/
function showDialogQueued(message) {
if (!isAlertOpen()) {
showDialog(message);
return;
}
var waited = 0;
var timer = global.setInterval(function () {
waited += 200;
if (!isAlertOpen()) {
global.clearInterval(timer);
showDialog(message);
} else if (waited >= 60000) {
global.clearInterval(timer);
}
}, 200);
}
/**
* HTTPS 가 아닌 연결에서 비밀번호를 입력하려는 화면에 경고를 띄운다.
*
* 정책이 NONE 이면 아무것도 하지 않는다. 문구와 노출 빈도는 이 브라우저가 봉투를 만들 수 있는지로 갈린다.
*
* <ul>
* <li>봉투를 만들 수 있음 - 정보성 경고다. 값은 암호화되어 나가고, 남는 위험은 중간자가 이 스크립트
* 자체를 바꿔치기하는 경우뿐이다. 매번 띄우면 방해만 되므로 <b>세션당 1회</b>.</li>
* <li>봉투를 만들 수 없음 - 비밀번호가 평문으로 나가거나(permissive), 서버가 거부해 로그인 자체가
* 안 된다(enforce). 사용자가 놓치면 안 되는 상태이므로 <b>화면을 열 때마다</b>.</li>
* </ul>
*/
function warnInsecureTransport() {
if (!cfg.enabled || !cfg.policy || cfg.policy === 'NONE') {
return;
}
if (!global.location || global.location.protocol === 'https:') {
return;
}
if (!global.document.querySelector('input[type="password"]')) {
return;
}
var encryptable = available();
if (encryptable && !markWarnedOnce()) {
return;
}
var message;
if (encryptable) {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>로 접속했습니다.<br>'
+ '비밀번호는 전송 전에 암호화되지만, 중간자 공격까지 막지는 못합니다.<br>'
+ '운영 환경에서는 HTTPS 로 접속하세요.';
} else if (cfg.policy === 'ENFORCE') {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
+ '서버가 암호화되지 않은 비밀번호를 거부하므로 로그인할 수 없습니다.<br>'
+ '관리자에게 문의하세요.';
} else {
message = '보안 경고<br>현재 <strong>HTTPS 가 아닌 연결(HTTP)</strong>이고, 이 브라우저에서는 '
+ '비밀번호 암호화를 사용할 수 없습니다.<br>'
+ '비밀번호가 <strong>암호화되지 않은 상태로</strong> 전송됩니다.';
}
showDialogQueued(message);
}
/**
* 이번 탭 세션에서 아직 경고를 안 띄웠으면 표시를 남기고 true 를 준다.
* 프라이빗 모드 등으로 sessionStorage 가 막혀 있으면 true — 안 띄우는 쪽보다 매번 띄우는 쪽이 안전하다.
*/
function markWarnedOnce() {
try {
if (!global.sessionStorage) {
return true;
}
if (global.sessionStorage.getItem(WARN_KEY) === '1') {
return false;
}
global.sessionStorage.setItem(WARN_KEY, '1');
} catch (e) {
// 접근 자체가 막힌 경우. 매번 띄운다.
}
return true;
}
// ---------- 선언적 훅 ----------
/**
@@ -232,19 +576,29 @@
});
}
function start() {
bindDeclarativeForms();
warnInsecureTransport();
}
// DOMContentLoaded 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
// 경고 팝업도 같은 시점이어야 custom-popups.js 와 팝업 프래그먼트가 준비된 뒤에 뜬다.
if (global.document) {
if (global.document.readyState === 'complete') {
bindDeclarativeForms();
start();
} else {
global.addEventListener('load', bindDeclarativeForms);
global.addEventListener('load', start);
}
}
global.portalPasswordCrypto = {
available: available,
engine: function () {
var current = available() ? engine() : null;
return current ? current.name : null;
},
encryptForm: encryptForm,
encryptValues: encryptValues
};
@@ -91,7 +91,10 @@
var seg = document.getElementById('tfaSegment');
seg.innerHTML = '';
self._maskedByType = {};
var channels = info.channels || [];
// SMS를 먼저 표시하고 첫 번째 수단을 기본 선택한다.
var channels = (info.channels || []).slice().sort(function (a, b) {
return (a.type === 'SMS' ? 0 : 1) - (b.type === 'SMS' ? 0 : 1);
});
channels.forEach(function (ch, idx) {
self._maskedByType[ch.type] = ch.masked;
var btn = document.createElement('button');
@@ -195,6 +195,11 @@
color: #212529;
font-size: 20px;
line-height: $line-height-normal;
// 한글은 기본값(normal)이면 음절 단위로 끊겨 "막지는 못합니 / 다" 처럼 잘린다.
// keep-all 로 어절(띄어쓰기) 단위 줄바꿈을 강제한다. 대신 공백 없는 긴 문자열(URL·키값)이
// 넘칠 수 있어 overflow-wrap 으로 그때만 강제 분리한다.
word-break: keep-all;
overflow-wrap: break-word;
p {
margin: 0;
@@ -118,6 +118,36 @@
color: #666;
}
.api-statistics-container .statistics-date-range {
width: 260px;
max-width: 100%;
cursor: pointer;
&:focus-visible {
outline: 2px solid #0049B4;
outline-offset: 2px;
border-radius: 6px;
}
}
// 공통 달력 CSS의 z-index: 0을 이 페이지의 팝업에만 보정한다.
.daterangepicker.statistics-date-picker {
z-index: 1100;
max-width: calc(100vw - 16px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
.applyBtn {
color: #fff;
background: #0049B4;
border-radius: 4px;
}
@media (max-width: 560px) {
max-height: 70vh;
overflow-y: auto;
}
}
.btn-search {
background: #0049B4;
border: none;
@@ -692,4 +722,4 @@
.stats-progress {
width: 100px;
}
}
}
@@ -529,8 +529,6 @@
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
&.is-ongoing { border-left-color: var(--as-warn); }
.as-maint-head {
display: flex;
align-items: baseline;
@@ -539,7 +537,7 @@
flex-wrap: wrap;
}
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-text); }
.as-maint-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--as-info); }
.as-schedule {
display: inline-flex;
@@ -554,8 +552,6 @@
font-variant-numeric: tabular-nums;
}
&.is-ongoing .as-schedule { color: var(--as-warn); background: var(--as-warn-bg); }
.as-maint-body { margin: 14px 0 12px; font-size: 14px; line-height: 1.6; color: var(--as-text-3); }
.as-maint-meta {
@@ -41,10 +41,11 @@
<div class="search-filter-row">
<!-- 일별: 날짜 범위 -->
<div class="date-range-picker" id="dailyFilter">
<input type="date" id="startDate" class="date-input"
<input type="text" id="statisticsDateRange" class="date-input statistics-date-range"
aria-label="일별 조회 기간" title="1년 전부터 오늘까지, 시작일·종료일을 포함해 최대 40일을 선택할 수 있습니다." readonly>
<input type="hidden" id="startDate"
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
<span class="date-separator">-</span>
<input type="date" id="endDate" class="date-input"
<input type="hidden" id="endDate"
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
</div>
@@ -57,7 +58,7 @@
</select>
</div>
<button type="button" class="btn-search" id="btnSearch">
<button type="button" class="btn-search" id="btnSearch" aria-label="통계 조회">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
@@ -335,6 +336,8 @@
let currentMode = 'DAILY';
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
const MAX_DATE_RANGE_DAYS = 40;
const MIN_QUERY_DATE = moment([[${ statsMinDate }]], 'YYYY-MM-DD', true);
const MAX_QUERY_DATE = moment([[${ statsMaxDate }]], 'YYYY-MM-DD', true);
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
function updateChart() {
@@ -460,16 +463,70 @@
return meta ? meta.getAttribute('content') : '';
}
// Validate date range (max 40 days)
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
function validateDateRange(startDate, endDate) {
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
var start = new Date(startDate), end = new Date(endDate);
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
if (start.isBefore(MIN_QUERY_DATE, 'day') || end.isAfter(MAX_QUERY_DATE, 'day')) {
return { valid: false, message: '조회 날짜는 1년 전부터 오늘까지만 선택할 수 있습니다.' };
}
if (end.diff(start, 'days') >= MAX_DATE_RANGE_DAYS) {
return { valid: false, message: '조회 기간은 시작일·종료일을 포함해 최대 ' + MAX_DATE_RANGE_DAYS + '일로 선택해주세요.' };
}
return { valid: true };
}
function initializeDateRangePicker() {
var input = $('#statisticsDateRange');
function updateRangeText() {
input.val(moment($('#startDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD') + ' - '
+ moment($('#endDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD'));
}
input.daterangepicker({
startDate: moment($('#startDate').val(), 'YYYY-MM-DD', true),
endDate: moment($('#endDate').val(), 'YYYY-MM-DD', true),
minDate: MIN_QUERY_DATE.clone(),
maxDate: MAX_QUERY_DATE.clone(),
maxSpan: { days: MAX_DATE_RANGE_DAYS - 1 },
autoUpdateInput: false,
opens: 'left',
drops: 'auto',
locale: {
format: 'YYYY.MM.DD',
separator: ' - ',
applyLabel: '조회',
cancelLabel: '취소',
daysOfWeek: ['일', '월', '화', '수', '목', '금', '토'],
monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
firstDay: 0
}
});
input.data('daterangepicker').container.addClass('statistics-date-picker');
updateRangeText();
input.on('show.daterangepicker', function (event, picker) {
// 조회하지 않고 바깥을 눌러 닫았다면 확정된 조회 조건으로 다시 연다.
picker.setStartDate(moment($('#startDate').val(), 'YYYY-MM-DD', true));
picker.setEndDate(moment($('#endDate').val(), 'YYYY-MM-DD', true));
picker.updateView();
});
input.on('apply.daterangepicker', function (event, picker) {
$('#startDate').val(picker.startDate.format('YYYY-MM-DD'));
$('#endDate').val(picker.endDate.format('YYYY-MM-DD'));
updateRangeText();
searchStatistics();
});
input.on('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
input.data('daterangepicker').show();
}
});
}
function buildPayload() {
return {
mode: currentMode,
@@ -505,8 +562,10 @@
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
}
if (p.startDate) $('#startDate').val(p.startDate);
if (p.endDate) $('#endDate').val(p.endDate);
if (validateDateRange(p.startDate, p.endDate).valid) {
$('#startDate').val(p.startDate);
$('#endDate').val(p.endDate);
}
if (p.month) $('#month').val(p.month);
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
return true;
@@ -563,12 +622,12 @@
// Event Handlers
$('#btnSearch').on('click', searchStatistics);
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
$('#startDate, #endDate').on('change', searchStatistics);
$('#month').on('change', searchStatistics);
$('#startDate, #endDate').on('keypress', function (e) { if (e.which === 13) searchStatistics(); });
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
if (restoreParams()) {
var restored = restoreParams();
initializeDateRangePicker();
if (restored) {
searchStatistics();
}
});
@@ -577,4 +636,4 @@
</body>
</html>
</html>
@@ -24,7 +24,7 @@
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
클라이언트에서 12건/페이지로 페이징(#apiPagination, api-selector.js PAGE_SIZE) — 전체선택/모달은 페이징과
PTL_PROPERTY(Portal / api.list.page-size, 기본 15) 기준으로 클라이언트 페이징 — 전체선택/모달은 페이징과
무관하게 필터된 전체 목록 기준으로 동작.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
*/-->
@@ -107,6 +107,7 @@
<script th:inline="javascript">
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
window.API_SELECTOR_PAGE_SIZE = /*[[${@apiListProperties.pageSize}]]*/ 15;
</script>
<script th:src="@{/js/api-selector.js}"></script>
</th:block>
@@ -32,12 +32,16 @@
};
</script>
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 브라우저가 Web Crypto 를
쓸 수 없으면(로컬 HTTP 등) 모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다. -->
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 봉투를 만들 수단이 전혀 없으면
모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다.
forgeUrl 은 crypto.subtle 을 못 쓰는 환경(원격 오리진 HTTP)에서만 동적으로 로드된다. -->
<script th:inline="javascript">
window.__PASSWORD_CRYPTO__ = {
enabled: /*[[${passwordCryptoEnabled}]]*/ false,
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json'
enabled: /*[[${passwordCrypto.enabled}]]*/ false,
policy: /*[[${passwordCrypto.policy}]]*/ 'NONE',
softwareFallback: /*[[${passwordCrypto.softwareFallback}]]*/ true,
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json',
forgeUrl: /*[[@{/js/lib/forge-crypto.min.js}]]*/ '/js/lib/forge-crypto.min.js'
};
</script>
<script th:src="@{/js/password-crypto.js}"></script>
@@ -1,8 +1,8 @@
<!-- fragment/popup/customPopups.html -->
<div th:fragment="customPopups">
<div th:replace="fragment/popup/customPopup :: #customAlert"></div>
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
<div th:replace="~{fragment/popup/customPopup :: #customAlert}"></div>
<div th:replace="~{fragment/popup/emailValidationPopup :: #emailValidationPopup}"></div>
<div th:replace="~{fragment/popup/customPopup2 :: #customConfirm}"></div>
<div th:replace="~{fragment/popup/passwordInputPopup :: passwordInputPopup}"></div>
<div th:replace="~{fragment/popup/terminateRequestPopup :: terminateRequestPopup}"></div>
</div>
@@ -3,7 +3,7 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/head :: headFragment">
<head th:replace="~{fragment/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -23,7 +23,7 @@
</th:block>
</div>
<footer th:replace="fragment/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
</body>
</html>
@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -20,9 +20,9 @@
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
@@ -3,7 +3,7 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -18,9 +18,9 @@
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
</body>
</html>
@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -18,7 +18,7 @@
<th:block layout:fragment="script">
</th:block>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
</body>
</html>
@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -12,7 +12,7 @@
<div class="loading-overlay" style="display: none;">
<div class="loading-spinner"></div>
</div>
<div class="navigation" th:replace="fragment/djbank/header_nav :: headerFragment"></div>
<div class="navigation" th:replace="~{fragment/djbank/header_nav :: headerFragment}"></div>
<div class="container">
<th:block layout:fragment="contentFragment">
</th:block>
@@ -21,9 +21,9 @@
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
@@ -3,7 +3,7 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/head :: headFragment">
<head th:replace="~{fragment/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -21,7 +21,7 @@
</th:block>
</div>
<footer th:replace="fragment/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
</body>
</html>
@@ -3,31 +3,31 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/head :: headFragment">
<head th:replace="~{fragment/head :: headFragment}">
<meta charset="utf-8">
</head>
<body>
<!-- header -->
<header th:replace="fragment/admin_header :: headerFragment"></header>
<th:block th:replace="fragment/admin_header :: headerScript"></th:block>
<nav th:replace="fragment/header :: navFragment"></nav>
<header th:replace="~{fragment/admin_header :: headerFragment}"></header>
<th:block th:replace="~{fragment/admin_header :: headerScript}"></th:block>
<nav th:replace="~{fragment/header :: navFragment}"></nav>
<!-- csrf -->
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<div>
<aside th:replace="fragment/menu :: menuFragment"></aside>
<th:block th:replace="fragment/menu :: menuScript"></th:block>
<aside th:replace="~{fragment/menu :: menuFragment}"></aside>
<th:block th:replace="~{fragment/menu :: menuScript}"></th:block>
<section class="content">
<div layout:fragment="contentFragment">
</div>
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
</section>
</div>
</body>
@@ -18,7 +18,7 @@
<div class="example">
<h2>2. 프래그먼트 대체</h2>
<header th:replace="fragment/header :: headerFragment"></header>
<header th:replace="~{fragment/header :: headerFragment}"></header>
</div>
<div class="example">
@@ -104,7 +104,7 @@
<div class="example">
<h2>14. 프래그먼트</h2>
<div th:include="fragment/footer :: footerFragment">여기에 푸터가 들어갑니다</div>
<div th:include="~{fragment/footer :: footerFragment}">여기에 푸터가 들어갑니다</div>
</div>
<div class="example">
@@ -0,0 +1,119 @@
package com.eactive.apim.portal.apps.apis.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.ui.ExtendedModelMap;
@ExtendWith(MockitoExtension.class)
class ApiControllerTest {
@Mock private ApiService apiService;
@Mock private ApiServiceService apiServiceService;
@Mock private ApiSearchFacade apiSearchFacade;
@Mock private ApiStatusCatalogService apiStatusCatalogService;
@Mock private PortalPropertyService portalPropertyService;
private ApiController controller;
private final List<ApiSpecInfoDto> apis = new ArrayList<>();
@BeforeEach
void setUp() {
controller = new ApiController(apiService, apiServiceService, apiSearchFacade,
apiStatusCatalogService, new ApiListProperties(portalPropertyService));
for (int i = 0; i < 32; i++) {
ApiSpecInfoDto api = new ApiSpecInfoDto();
api.setApiId("api-" + i);
apis.add(api);
}
Map<String, Object> result = new HashMap<>();
result.put("apis", apis);
result.put("totalApiCount", apis.size());
result.put("selectedApiCount", apis.size());
when(apiSearchFacade.searchApis(any(ApiGroupSearch.class))).thenReturn(result);
}
@Test
void defaultSizePaginatesByFifteen() {
stubPageSize("15");
Page<?> first = listPage(0);
assertEquals(15, first.getNumberOfElements());
assertEquals(3, first.getTotalPages());
assertEquals(32, first.getTotalElements());
assertEquals(apis.subList(0, 15), first.getContent());
assertEquals(apis.subList(15, 30), listPage(1).getContent());
assertEquals(apis.subList(30, 32), listPage(2).getContent());
}
@Test
void propertyChangesApplyOnNextRequestAndOverrideRequestSize() {
stubPageSize(" 6 ");
Page<?> page = listPage(1);
assertEquals(6, page.getSize());
assertEquals(apis.subList(6, 12), page.getContent());
stubPageSize("20");
assertEquals(apis.subList(20, 32), listPage(1).getContent());
}
@Test
void invalidPropertyFallsBackToFifteen() {
for (String value : new String[] {null, "", " ", "0", "-1", "invalid", "1.5", "2147483648"}) {
stubPageSize(value);
assertEquals(15, listPage(0).getSize(), "설정값: " + value);
}
}
@Test
void largePageOffsetDoesNotOverflow() {
stubPageSize("15");
assertTrue(listPage(Integer.MAX_VALUE - 1).isEmpty());
}
@Test
void emptySearchResultsProduceEmptyPage() {
stubPageSize("15");
apis.clear();
Page<?> page = listPage(0);
assertTrue(page.isEmpty());
assertEquals(0, page.getTotalElements());
}
private void stubPageSize(String value) {
when(portalPropertyService.getOrCreateProperty(
eq("Portal"), eq("api.list.page-size"), eq("15"), anyString())).thenReturn(value);
}
private Page<?> listPage(int pageNumber) {
ExtendedModelMap model = new ExtendedModelMap();
assertEquals("apps/apis/mainApiList", controller.apiList(
new ApiGroupSearch(), PageRequest.of(pageNumber, 10), model));
Page<?> page = (Page<?>) model.get("page");
assertEquals(page.getContent(), model.get("apis"));
return page;
}
}
@@ -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 + "\"}";
}
}
@@ -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());
}
}
@@ -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; }
}
}
@@ -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", "테스트 환경 안내");
}
}
}
}
}
}
+46
View File
@@ -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도 갱신한다.
+28
View File
@@ -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>
+161
View File
@@ -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'
});
});
+21
View File
@@ -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' } }
]
});
+215
View File
@@ -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>
+23
View File
@@ -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;