포탈 암호화 모듈 추가: forge-crypto.min.js 라이브러리 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

This commit is contained in:
Rinjae
2026-09-02 16:59:25 +09:00
parent b2787882bf
commit 53c3b0ee7e
20 changed files with 1075 additions and 138 deletions
@@ -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;
}
/**
@@ -37,6 +37,13 @@ public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
private Map<String, String[]> parameterMapCache;
/**
* 평문 비밀번호 경고를 요청당 한 번만 남기기 위한 표시.
* 파라미터는 컨트롤러·검증기에서 여러 번 조회되고, 같은 폼에 비밀번호 계열 필드가 둘 이상인
* 경우도 흔해서(비밀번호/비밀번호확인) 그대로 두면 한 번의 제출이 로그 여러 줄을 만든다.
*/
private boolean plaintextWarned;
public DecryptingRequestWrapper(HttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
@@ -97,13 +104,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 +138,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,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();
}
}
+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
+5 -2
View File
@@ -41,7 +41,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:
+47 -45
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;
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
File diff suppressed because one or more lines are too long
+325 -52
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);
}
@@ -270,6 +414,125 @@
});
}
// ---------- 전송로 경고 ----------
/**
* 팝업을 띄운다. 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;
}
// ---------- 선언적 훅 ----------
/**
@@ -313,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
};
@@ -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;
@@ -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>