비밀번호 전송 암호화 기능 추가:

- RSA-OAEP + AES-GCM 기반 비밀번호 전송 암호화 모듈 구현
- 클라이언트 공개키 발급 및 복호화 처리 로직 개발
- 암호화 설정 속성 및 요청별/세션별/서버별 키 보관소 구성
This commit is contained in:
Rinjae
2026-09-01 15:06:30 +09:00
parent c732f1e1b2
commit 59c379b22c
33 changed files with 1292 additions and 25 deletions
@@ -16,6 +16,7 @@ import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
import com.eactive.apim.portal.djb.footer.RelatedSite;
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
import com.eactive.apim.portal.djb.guide.GuideProperty;
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
@ControllerAdvice
@@ -51,6 +52,9 @@ public class GlobalControllerAdvice {
@Autowired
private Environment environment;
@Autowired
private PasswordCryptoProperties passwordCryptoProperties;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -125,6 +129,16 @@ public class GlobalControllerAdvice {
return passwordPolicyProperties.isKeyboardSequenceBlocked();
}
/**
* 비밀번호 전송암호화 사용 여부(yml portal.security.password-encrypt.enabled).
* head 의 window.__PASSWORD_CRYPTO__ 로 내려가 password-crypto.js 가 읽는다.
* 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 화면은 평문으로 폴백한다.
*/
@ModelAttribute("passwordCryptoEnabled")
public boolean passwordCryptoEnabled() {
return passwordCryptoProperties.isEnabled();
}
/**
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
*/
@@ -1,9 +1,7 @@
package com.eactive.apim.portal.common.exception;
import java.util.Arrays;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
@@ -195,10 +193,9 @@ public class PortalGlobalExceptionHandler {
*/
@ExceptionHandler(value = Exception.class)
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
String requestParams = request.getParameterMap().entrySet()
.stream()
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
.collect(Collectors.joining(", "));
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
// 이 시점의 파라미터는 이미 복호화된 평문이다.
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
@@ -0,0 +1,148 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.security.PrivateKey;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* 봉투(ENC1) 형식으로 들어온 파라미터 값을 평문으로 되돌리는 요청 래퍼.
*
* <p><b>지연 복호화가 필수다.</b> 생성자에서 {@code getParameterMap()} 을 부르면 컨테이너가 요청 본문을
* 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다.
* 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.</p>
*
* <p>Lucy XSS 필터({@code order = MIN_VALUE + 1})보다 <b>앞</b>에서 이 래퍼가 씌워지므로,
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 비밀번호의
* 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.</p>
*/
@Slf4j
public class DecryptingRequestWrapper extends HttpServletRequestWrapper {
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
private final PasswordCryptoProperties properties;
/** 봉투 원문 → 평문. 같은 값이 여러 번 조회돼도 RSA 연산은 한 번만 한다. */
private final Map<String, String> decrypted = new HashMap<>();
/** 요청 처리 후 폐기할 keyId(1회용 키 모드). */
private final Set<String> usedKeyIds = new LinkedHashSet<>();
private Map<String, String[]> parameterMapCache;
public DecryptingRequestWrapper(HttpServletRequest request,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore,
PasswordCryptoProperties properties) {
super(request);
this.codec = codec;
this.keyStore = keyStore;
this.properties = properties;
}
@Override
public String getParameter(String name) {
return convert(name, super.getParameter(name));
}
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) {
return null;
}
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
return converted;
}
@Override
public Map<String, String[]> getParameterMap() {
if (parameterMapCache == null) {
Map<String, String[]> source = super.getParameterMap();
Map<String, String[]> result = new HashMap<>(Math.max(16, source.size() * 2));
for (Map.Entry<String, String[]> entry : source.entrySet()) {
String name = entry.getKey();
String[] values = entry.getValue();
String[] converted = new String[values.length];
for (int i = 0; i < values.length; i++) {
converted[i] = convert(name, values[i]);
}
result.put(name, converted);
}
parameterMapCache = Collections.unmodifiableMap(result);
}
return parameterMapCache;
}
/** 요청 처리가 끝난 뒤 필터가 호출한다. 1회용 키를 폐기해 재전송을 막는다. */
void consumeUsedKeys() {
for (String keyId : usedKeyIds) {
keyStore.consume(keyId, (HttpServletRequest) getRequest());
}
usedKeyIds.clear();
}
private String convert(String name, String value) {
if (value == null || value.isEmpty()) {
return value;
}
if (!codec.isEnvelope(value)) {
// strict 모드에서는 평문 비밀번호를 받아들이지 않는다. 예외를 던지면 로그인 경로가 500 이 되므로
// 빈 값으로 바꿔 기존 인증 실패 흐름(아이디/비밀번호 확인)을 타게 한다.
if (properties.isStrict() && PasswordParamNames.isPasswordLike(name)) {
log.warn("암호화되지 않은 비밀번호 파라미터 거부(strict) - uri={}, param={}", getRequestURI(), name);
return "";
}
return value;
}
String cached = decrypted.get(value);
if (cached != null) {
return cached;
}
String keyId = codec.keyIdOf(value);
if (keyId == null) {
log.warn("봉투 형식 오류 - uri={}, param={}", getRequestURI(), name);
return failed(value);
}
PrivateKey privateKey = keyStore.resolve(keyId, (HttpServletRequest) getRequest());
if (privateKey == null) {
log.warn("전송암호화 키를 찾을 수 없음(만료·인스턴스 불일치) - uri={}, param={}, keyId={}",
getRequestURI(), name, keyId);
return failed(value);
}
try {
String plain = codec.decrypt(value, privateKey);
decrypted.put(value, plain);
usedKeyIds.add(keyId);
return plain;
} catch (PasswordDecryptException e) {
log.warn("전송암호화 복호화 실패 - uri={}, param={}, keyId={}, reason={}",
getRequestURI(), name, keyId, e.getMessage());
usedKeyIds.add(keyId);
return failed(value);
}
}
/**
* 복호화 실패 시의 값. 봉투 문자열을 그대로 흘려보내면 비밀번호 정책 검증 등이 엉뚱하게 통과할 수 있어
* 빈 값으로 바꾼다. 결과적으로 사용자에게는 일반적인 입력값 오류로 보인다.
*/
private String failed(String rawValue) {
decrypted.put(rawValue, "");
return "";
}
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 클라이언트에 내려줄 공개키 1건. {@code PasswordKeyController} 응답 본문이기도 하다.
*/
@Data
@AllArgsConstructor
public class IssuedKey {
/** 봉투(ENC1)의 두 번째 세그먼트로 되돌아오는 키 식별자. */
private final String keyId;
/** X.509 SubjectPublicKeyInfo(SPKI) DER 을 표준 base64 로 인코딩한 값. */
private final String publicKey;
/** 남은 수명(초). 클라이언트 캐시 판단용. */
private final int expiresIn;
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 비밀번호 전송암호화 설정. {@code portal.security.password-encrypt.*}
*
* <p>브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다.
* 로컬 개발환경은 HTTP(비 secure context)라 브라우저 {@code crypto.subtle} 을 쓸 수 없으므로
* {@link #enabled} 를 꺼둔다. 켜져 있어도 브라우저가 지원하지 못하면 클라이언트가 평문으로 폴백한다.</p>
*
* <p><b>이 기능은 XSS 방어가 아니다.</b> 스크립트가 주입되면 입력창에서 직접 탈취할 수 있다.
* "전송 페이로드 평문 노출" 점검 지적에 대한 대응 범위로만 이해할 것.</p>
*/
@Data
@Component
@ConfigurationProperties(prefix = "portal.security.password-encrypt")
public class PasswordCryptoProperties {
/**
* RSA 개인키 보관 범위.
*
* <ul>
* <li>{@code REQUEST} - 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기. 재전송 공격 차단이 가장 강하다.</li>
* <li>{@code SESSION} - 세션 단위로 키를 보관. 세션 복제 환경에 유리하다.</li>
* <li>{@code SERVER} - 서버 고정 키쌍 + TTL 로테이션. 무상태라 가장 단순하지만 재전송 방지 수단이 없다.</li>
* </ul>
*/
public enum KeyScope {
REQUEST, SESSION, SERVER
}
/** 마스터 스위치. 꺼져 있으면 필터·엔드포인트가 모두 무동작이고 화면은 평문 전송한다. */
private boolean enabled = false;
/** 키 보관 범위. */
private KeyScope keyScope = KeyScope.REQUEST;
/** 발급된 키의 수명(초). REQUEST/SESSION 은 만료 기준, SERVER 는 로테이션 주기. */
private int keyTtlSeconds = 300;
/**
* true 면 서버가 평문 비밀번호 파라미터를 거부한다(빈 값으로 치환 → 인증 실패).
* JS 비활성 사용자나 구형 브라우저가 로그인하지 못하게 되므로 안정화 후에만 켠다.
*/
private boolean strict = false;
/** RSA 키 길이. */
private int rsaKeySize = 2048;
}
@@ -0,0 +1,18 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 봉투 복호화 실패. 화면으로 스택을 올리지 않고 필터에서 흡수한다.
* 메시지에 평문이나 키 자료를 담지 않는다.
*/
public class PasswordDecryptException extends RuntimeException {
private static final long serialVersionUID = 1L;
public PasswordDecryptException(String message) {
super(message);
}
public PasswordDecryptException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,61 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 봉투(ENC1) 파라미터를 평문으로 되돌리는 서블릿 필터.
*
* <p>{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE} 로 등록해
* Lucy XSS 필터({@code MIN_VALUE + 1})보다 먼저 실행된다. 자세한 이유는
* {@link DecryptingRequestWrapper} 주석 참고.</p>
*
* <p>기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만
* 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.</p>
*/
@Component
public class PasswordDecryptFilter implements Filter {
private final PasswordCryptoProperties properties;
private final PasswordEnvelopeCodec codec;
private final PasswordKeyStore keyStore;
public PasswordDecryptFilter(PasswordCryptoProperties properties,
PasswordEnvelopeCodec codec,
PasswordKeyStore keyStore) {
this.properties = properties;
this.codec = codec;
this.keyStore = keyStore;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!properties.isEnabled() || !(request instanceof HttpServletRequest)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (!"POST".equalsIgnoreCase(httpRequest.getMethod())) {
chain.doFilter(request, response);
return;
}
DecryptingRequestWrapper wrapper =
new DecryptingRequestWrapper(httpRequest, codec, keyStore, properties);
try {
chain.doFilter(wrapper, response);
} finally {
wrapper.consumeUsedKeys();
}
}
}
@@ -0,0 +1,104 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.PrivateKey;
import java.security.spec.MGF1ParameterSpec;
import java.util.Base64;
/**
* 클라이언트가 만든 봉투(envelope) 문자열의 파싱·복호화.
*
* <pre>
* ENC1.&lt;keyId&gt;.&lt;b64url(RSA-OAEP(AES키))&gt;.&lt;b64url(iv 12B)&gt;.&lt;b64url(AES-GCM 암호문+태그)&gt;
* </pre>
*
* <p>세그먼트는 base64<b>url</b>(패딩 없음)이라 Lucy XSS 이스케이프와 urlencode 를 모두 통과해도
* 값이 변형되지 않는다.</p>
*
* <p>RSA-OAEP 는 반드시 MGF1 해시까지 SHA-256 으로 지정해야 한다. SunJCE 는
* {@code OAEPWithSHA-256AndMGF1Padding} 만 지정하면 MGF1 에 SHA-1 을 쓰는데,
* 브라우저 Web Crypto 의 {@code RSA-OAEP + SHA-256} 은 MGF1 도 SHA-256 이라 그대로 두면
* 복호화가 실패한다.</p>
*/
@Component
public class PasswordEnvelopeCodec {
public static final String PREFIX = "ENC1.";
/** AES-GCM 인증 태그 길이(비트). Web Crypto 기본값과 동일. */
private static final int GCM_TAG_BITS = 128;
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
/** 값이 봉투 형식인지. 파싱 비용 없이 접두사만 본다. */
public boolean isEnvelope(String value) {
return value != null && value.startsWith(PREFIX);
}
/** 봉투에서 keyId 만 추출. 형식이 어긋나면 {@code null}. */
public String keyIdOf(String value) {
String[] parts = split(value);
return parts == null ? null : parts[1];
}
/**
* RSA 개인키로 AES 키를 풀고 AES-GCM 으로 본문을 복호화한다.
*
* @throws PasswordDecryptException 형식 오류·키 불일치·태그 검증 실패
*/
public String decrypt(String value, PrivateKey privateKey) {
String[] parts = split(value);
if (parts == null) {
throw new PasswordDecryptException("봉투 형식이 올바르지 않다");
}
try {
Key aesKey = unwrapAesKey(URL_DECODER.decode(parts[2]), privateKey);
byte[] iv = URL_DECODER.decode(parts[3]);
byte[] cipherText = URL_DECODER.decode(parts[4]);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
} catch (PasswordDecryptException e) {
throw e;
} catch (Exception e) {
throw new PasswordDecryptException("봉투 복호화 실패", e);
}
}
private Key unwrapAesKey(byte[] wrapped, PrivateKey privateKey) {
try {
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsa.init(Cipher.DECRYPT_MODE, privateKey, new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
return new SecretKeySpec(rsa.doFinal(wrapped), "AES");
} catch (Exception e) {
throw new PasswordDecryptException("AES 키 언랩 실패", e);
}
}
/** {@code ENC1.keyId.encKey.iv.cipher} 5개 세그먼트. 어긋나면 null. */
private String[] split(String value) {
if (!isEnvelope(value)) {
return null;
}
String[] parts = value.split("\\.", 5);
if (parts.length != 5) {
return null;
}
for (String part : parts) {
if (part.isEmpty()) {
return null;
}
}
return parts;
}
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Map;
/**
* 비밀번호 전송암호화용 공개키 발급.
*
* <p>경로를 {@code /api/**} 아래에 둔 이유: {@code PortalConfigWebDispatcherServlet#addInterceptors}
* 가 {@code /api/**} 를 인터셉터(비밀번호 변경 강제·step-up 가드)에서 제외한다. 로그인 전 익명 상태에서
* 호출되므로 {@code /api/session/csrf} 와 같은 자리에 있어야 리다이렉트에 걸리지 않는다.</p>
*
* <p>GET 이라 CSRF 대상이 아니고, 공개키만 나가므로 인증도 요구하지 않는다.</p>
*/
@RestController
@RequestMapping("/api/security")
public class PasswordKeyController {
private final PasswordCryptoProperties properties;
private final PasswordKeyStore keyStore;
public PasswordKeyController(PasswordCryptoProperties properties, PasswordKeyStore keyStore) {
this.properties = properties;
this.keyStore = keyStore;
}
@GetMapping("/password-key.json")
public ResponseEntity<Map<String, Object>> issue(HttpServletRequest request) {
if (!properties.isEnabled()) {
// 클라이언트가 조용히 평문 폴백하도록 200 + enabled:false 로 답한다.
return noStore().body(Collections.<String, Object>singletonMap("enabled", Boolean.FALSE));
}
IssuedKey key = keyStore.issue(request);
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("enabled", Boolean.TRUE);
body.put("alg", "RSA-OAEP-256");
body.put("keyId", key.getKeyId());
body.put("publicKey", key.getPublicKey());
body.put("expiresIn", key.getExpiresIn());
// REQUEST 는 1회용이라 클라이언트가 캐시하면 안 된다. 나머지는 만료까지 재사용해
// 비밀번호 검증용 ajax 가 호출마다 RSA 키쌍을 만들게 하지 않는다.
body.put("keyScope", properties.getKeyScope().name());
return noStore().body(body);
}
private ResponseEntity.BodyBuilder noStore() {
return ResponseEntity.ok().cacheControl(CacheControl.noStore());
}
}
@@ -0,0 +1,52 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.UUID;
/**
* RSA 키쌍 생성/인코딩 헬퍼. 키 보관소 구현들이 공유한다.
*/
final class PasswordKeyPairs {
private PasswordKeyPairs() {
}
static KeyPair generate(int keySize) {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(keySize);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("RSA 키쌍 생성 실패", e);
}
}
static String newKeyId() {
return UUID.randomUUID().toString().replace("-", "");
}
/** 브라우저 {@code crypto.subtle.importKey('spki', ...)} 가 그대로 먹는 형식. */
static String toSpkiBase64(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
/**
* PKCS#8 바이트 → PrivateKey. 세션 복제(직렬화) 환경을 고려해
* {@link SessionScopedPasswordKeyStore} 는 키 객체 대신 바이트를 보관한다.
*/
static PrivateKey toPrivateKey(byte[] pkcs8) {
try {
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("RSA 개인키 복원 실패", e);
}
}
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* 비밀번호 전송암호화용 RSA 키쌍 보관소. 구현체는 {@link PasswordCryptoProperties.KeyScope} 별로 존재하며
* {@link PasswordKeyStoreRouter} 가 설정값에 따라 위임한다.
*/
public interface PasswordKeyStore {
/** 새 공개키를 발급한다(구현에 따라 기존 키 재사용). */
IssuedKey issue(HttpServletRequest request);
/** keyId 에 대응하는 개인키. 없거나 만료됐으면 {@code null}. */
PrivateKey resolve(String keyId, HttpServletRequest request);
/** 요청 처리가 끝난 뒤 호출. 1회용 키를 폐기하는 구현에서만 의미가 있다. */
void consume(String keyId, HttpServletRequest request);
}
@@ -0,0 +1,57 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.PrivateKey;
/**
* {@code portal.security.password-encrypt.key-scope} 설정값에 따라 실제 보관소로 위임한다.
* 설정은 yml 이므로 기동 시점에 고정된다(런타임 변경 시 재기동 필요).
*/
@Primary
@Component
public class PasswordKeyStoreRouter implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private final RequestScopedPasswordKeyStore requestScoped;
private final SessionScopedPasswordKeyStore sessionScoped;
private final ServerScopedPasswordKeyStore serverScoped;
public PasswordKeyStoreRouter(PasswordCryptoProperties properties,
RequestScopedPasswordKeyStore requestScoped,
SessionScopedPasswordKeyStore sessionScoped,
ServerScopedPasswordKeyStore serverScoped) {
this.properties = properties;
this.requestScoped = requestScoped;
this.sessionScoped = sessionScoped;
this.serverScoped = serverScoped;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
return delegate().issue(request);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
return delegate().resolve(keyId, request);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
delegate().consume(keyId, request);
}
private PasswordKeyStore delegate() {
PasswordCryptoProperties.KeyScope scope = properties.getKeyScope();
if (scope == PasswordCryptoProperties.KeyScope.SESSION) {
return sessionScoped;
}
if (scope == PasswordCryptoProperties.KeyScope.SERVER) {
return serverScoped;
}
return requestScoped;
}
}
@@ -0,0 +1,22 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
/**
* 비밀번호 계열 파라미터명 판별. {@code strict} 모드에서 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다.
*
* <p>실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword},
* {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다.
* 향후 축약형이 생길 것을 대비해 {@code passwd}/{@code pwd} 도 함께 본다.</p>
*/
final class PasswordParamNames {
private PasswordParamNames() {
}
static boolean isPasswordLike(String name) {
if (name == null) {
return false;
}
String lower = name.toLowerCase();
return lower.contains("password") || lower.contains("passwd") || lower.contains("pwd");
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기하는 보관소({@code key-scope: REQUEST}).
*
* <p>같은 keyId 로 두 번 복호화할 수 없으므로 재전송(replay) 공격이 차단된다. 개인키가 인스턴스
* 로컬 메모리에만 있으므로 WebLogic 다중 인스턴스에서는 스티키 세션이 전제다(현재 CSRF·단일세션
* 강제가 이미 세션 고정을 전제한다).</p>
*/
@Slf4j
@Component
public class RequestScopedPasswordKeyStore implements PasswordKeyStore {
/** 메모리 폭주 방지 상한. 초과 시 만료 스윕 후에도 남으면 발급을 거절하지 않고 가장 오래된 것부터 버린다. */
private static final int MAX_ENTRIES = 20_000;
private final PasswordCryptoProperties properties;
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
public RequestScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
sweep();
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
String keyId = PasswordKeyPairs.newKeyId();
int ttl = properties.getKeyTtlSeconds();
entries.put(keyId, new Entry(keyPair.getPrivate(), System.currentTimeMillis() + ttl * 1000L));
return new IssuedKey(keyId, PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()), ttl);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Entry entry = entries.get(keyId);
if (entry == null) {
return null;
}
if (entry.expiresAt < System.currentTimeMillis()) {
entries.remove(keyId);
return null;
}
return entry.privateKey;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
entries.remove(keyId);
}
/** 만료 항목 정리. 발급 시점에만 돌리므로 별도 스케줄러가 필요 없다. */
private void sweep() {
long now = System.currentTimeMillis();
Iterator<Map.Entry<String, Entry>> it = entries.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().expiresAt < now) {
it.remove();
}
}
if (entries.size() >= MAX_ENTRIES) {
log.warn("비밀번호 전송암호화 키 보관소 상한 초과 - size={}, 오래된 항목을 버린다", entries.size());
Iterator<Map.Entry<String, Entry>> overflow = entries.entrySet().iterator();
while (overflow.hasNext() && entries.size() >= MAX_ENTRIES) {
overflow.next();
overflow.remove();
}
}
}
private static final class Entry {
private final PrivateKey privateKey;
private final long expiresAt;
private Entry(PrivateKey privateKey, long expiresAt) {
this.privateKey = privateKey;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,81 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 서버 고정 키쌍 + TTL 로테이션 구현({@code key-scope: SERVER}).
*
* <p>직전 키를 1개 유예 보관해, 로테이션 순간에 이미 공개키를 받아 간 폼이 제출돼도 복호화된다.
* 재전송 방지 수단은 없으므로(같은 봉투를 여러 번 보내도 복호화됨) 인증 실패횟수 제한·계정 잠금에
* 의존한다. 무상태라 다중 인스턴스에서도 각자 동작하지만, 인스턴스마다 키가 달라
* 공개키 발급과 폼 제출이 같은 인스턴스로 가야 한다.</p>
*/
@Component
public class ServerScopedPasswordKeyStore implements PasswordKeyStore {
private final PasswordCryptoProperties properties;
private volatile Holder current;
private volatile Holder previous;
public ServerScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
Holder holder = currentHolder();
int remaining = (int) Math.max(1, (holder.expiresAt - System.currentTimeMillis()) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
Holder holder = current;
if (holder != null && holder.keyId.equals(keyId)) {
return holder.privateKey;
}
Holder old = previous;
if (old != null && old.keyId.equals(keyId)) {
return old.privateKey;
}
return null;
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 고정 키라 폐기하지 않는다.
}
private synchronized Holder currentHolder() {
long now = System.currentTimeMillis();
if (current == null || current.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
previous = current;
current = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
}
return current;
}
private static final class Holder {
private final String keyId;
private final PrivateKey privateKey;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, PrivateKey privateKey, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKey = privateKey;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -0,0 +1,84 @@
package com.eactive.apim.portal.common.security.passwordcrypto;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.Serializable;
import java.security.KeyPair;
import java.security.PrivateKey;
/**
* 세션 단위로 키쌍을 보관하는 구현({@code key-scope: SESSION}).
*
* <p>개인키를 객체가 아니라 PKCS#8 바이트로 들고 있어 세션 복제(직렬화)에도 안전하다.
* 같은 keyId 가 세션 수명 동안 재사용되므로 재전송 방지는 IV 랜덤성과 애플리케이션의
* 인증 실패횟수 제한에 의존한다.</p>
*/
@Component
public class SessionScopedPasswordKeyStore implements PasswordKeyStore {
private static final String SESSION_ATTR = "DJB_PWD_CRYPTO_KEY";
private final PasswordCryptoProperties properties;
public SessionScopedPasswordKeyStore(PasswordCryptoProperties properties) {
this.properties = properties;
}
@Override
public IssuedKey issue(HttpServletRequest request) {
HttpSession session = request.getSession(true);
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
long now = System.currentTimeMillis();
if (holder == null || holder.expiresAt < now) {
KeyPair keyPair = PasswordKeyPairs.generate(properties.getRsaKeySize());
holder = new Holder(
PasswordKeyPairs.newKeyId(),
keyPair.getPrivate().getEncoded(),
PasswordKeyPairs.toSpkiBase64(keyPair.getPublic()),
now + properties.getKeyTtlSeconds() * 1000L);
session.setAttribute(SESSION_ATTR, holder);
}
int remaining = (int) Math.max(1, (holder.expiresAt - now) / 1000L);
return new IssuedKey(holder.keyId, holder.publicKeySpki, remaining);
}
@Override
public PrivateKey resolve(String keyId, HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
Holder holder = (Holder) session.getAttribute(SESSION_ATTR);
if (holder == null || !holder.keyId.equals(keyId)) {
return null;
}
if (holder.expiresAt < System.currentTimeMillis()) {
session.removeAttribute(SESSION_ATTR);
return null;
}
return PasswordKeyPairs.toPrivateKey(holder.privateKeyPkcs8);
}
@Override
public void consume(String keyId, HttpServletRequest request) {
// 세션 수명 동안 재사용한다.
}
private static final class Holder implements Serializable {
private static final long serialVersionUID = 1L;
private final String keyId;
private final byte[] privateKeyPkcs8;
private final String publicKeySpki;
private final long expiresAt;
private Holder(String keyId, byte[] privateKeyPkcs8, String publicKeySpki, long expiresAt) {
this.keyId = keyId;
this.privateKeyPkcs8 = privateKeyPkcs8;
this.publicKeySpki = publicKeySpki;
this.expiresAt = expiresAt;
}
}
}
@@ -172,6 +172,50 @@ public class StringMaskingUtil {
return value;
}
/** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */
private static final String[] SENSITIVE_PARAM_KEYWORDS = {
"password", "passwd", "pwd", "secret", "credential", "token"};
/**
* 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다.
*
* <p>비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로,
* 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.</p>
*
* <pre>id=[user@a.com], password=[****]</pre>
*/
public static String maskParameterMap(java.util.Map<String, String[]> parameterMap) {
if (parameterMap == null || parameterMap.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(entry.getKey()).append('=');
if (isSensitiveParamName(entry.getKey())) {
sb.append("[****]");
} else {
sb.append(Arrays.toString(entry.getValue()));
}
}
return sb.toString();
}
private static boolean isSensitiveParamName(String name) {
if (!isValidString(name)) {
return false;
}
String lower = name.toLowerCase();
for (String keyword : SENSITIVE_PARAM_KEYWORDS) {
if (lower.contains(keyword)) {
return true;
}
}
return false;
}
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
@@ -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.PasswordDecryptFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -64,6 +65,20 @@ public class PortalConfigSecurity {
return registration;
}
/**
* 비밀번호 전송암호화 복호화 필터. Lucy XSS 필터(order = MIN_VALUE + 1)보다 <b>먼저</b> 실행되어야 한다.
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 특수문자가 든 비밀번호의 해시 비교 결과가
* 지금과 동일하게 유지된다. 순서를 뒤집으면 기존 계정 로그인이 깨진다.
*/
@Bean
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
PasswordDecryptFilter filter) {
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
registrationBean.setOrder(Integer.MIN_VALUE);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
@Bean
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
+4
View File
@@ -40,6 +40,10 @@ gateway:
portal:
# auth-virtual-code: 654321
security:
# 개발 서버는 HTTPS 가 아니라 브라우저 Web Crypto 를 쓸 수 없다.
password-encrypt:
enabled: false
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+9 -1
View File
@@ -36,4 +36,12 @@ server:
# 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다.
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
forward-headers-strategy: framework
forward-headers-strategy: framework
portal:
security:
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
# strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가).
password-encrypt:
enabled: true
strict: false
+4
View File
@@ -22,6 +22,10 @@ spring:
portal:
# 검증 단계에선 사용하지 않음
# auth-virtual-code: 654321
security:
# HTTPS 구간이라 전송암호화를 켠다. strict 는 운영 승격 전 검증 후 판단.
password-encrypt:
enabled: true
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
hot-reload-pages: true
+13
View File
@@ -153,6 +153,19 @@ portal:
user-approval: true
password-expiration-days: 90
security:
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
# 로컬/개발 서버는 HTTP(비 secure context)라 브라우저 crypto.subtle 을 못 쓰므로 기본은 꺼둔다.
# 켜져 있어도 브라우저가 지원하지 못하면 화면이 평문으로 폴백한다.
password-encrypt:
enabled: false
# REQUEST(폼당 1회용) | SESSION(세션당) | SERVER(서버 고정 + TTL 로테이션)
key-scope: REQUEST
key-ttl-seconds: 300
# true 면 평문 비밀번호 파라미터를 거부한다. JS 비활성 사용자가 로그인하지 못하므로 안정화 후에만 켠다.
strict: false
rsa-key-size: 2048
pages:
- path-pattern: /dashboard/daily_usage
method: GET
@@ -0,0 +1,251 @@
/**
* 비밀번호 전송암호화 (RSA-OAEP + AES-GCM 하이브리드).
*
* 브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막는다.
* 서버 공개키로 AES-256-GCM 키를 감싸고, 비밀번호는 그 AES 키로 암호화해 아래 봉투로 보낸다.
*
* ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(암호문+태그)>
*
* 서버(PasswordDecryptFilter)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
*
* 폴백: 설정이 꺼져 있거나(window.__PASSWORD_CRYPTO__.enabled=false), secure context 가 아니거나
* (로컬 HTTP 개발환경), crypto.subtle 이 없으면 아무 일도 하지 않고 평문 그대로 전송한다.
* 키 조회·암호화 중 오류가 나도 마찬가지다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다.
*
* 주의: 이 조치는 XSS 방어가 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있다.
*/
(function (global) {
'use strict';
var cfg = global.__PASSWORD_CRYPTO__ || {};
var PREFIX = 'ENC1.';
function available() {
return !!(cfg.enabled
&& global.isSecureContext
&& global.crypto
&& global.crypto.subtle
&& global.fetch
&& global.Promise);
}
// ---------- 인코딩 유틸 ----------
function toBase64Url(buffer) {
var bytes = new Uint8Array(buffer);
var binary = '';
for (var i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64ToBytes(base64) {
var binary = global.atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// ---------- 키 조회 ----------
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
var cachedKey = null;
/**
* 서버에서 공개키를 받아 import 한다.
*
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
* 생성하게 만들지 않기 위함이다.
*/
function loadKey() {
if (cachedKey && cachedKey.expiresAt > Date.now()) {
return global.Promise.resolve(cachedKey);
}
return global.fetch(cfg.keyUrl, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: { 'Accept': 'application/json' }
}).then(function (response) {
if (!response.ok) {
throw new Error('공개키 조회 실패: HTTP ' + response.status);
}
return response.json();
}).then(function (data) {
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) {
var key = { keyId: data.keyId, publicKey: publicKey };
if (data.keyScope && data.keyScope !== 'REQUEST') {
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
var ttlMs = Math.max(0, (data.expiresIn || 0) - 30) * 1000;
if (ttlMs > 0) {
key.expiresAt = Date.now() + ttlMs;
cachedKey = key;
}
}
return key;
});
});
}
// ---------- 암호화 ----------
/**
* 값 여러 개를 한 번에 암호화한다. AES 키와 RSA 랩은 1회만 하고 IV 만 값마다 새로 만든다.
* @param {Object} values 이름 → 평문
* @returns {Promise<Object>} 이름 → 봉투 문자열
*/
function encryptValues(values) {
var names = Object.keys(values || {}).filter(function (name) {
var v = values[name];
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
});
if (!available() || 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) {
var out = {};
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
results.forEach(function (result) { out[result.name] = result.envelope; });
return out;
}).catch(function (error) {
// 암호화 실패는 화면을 막지 않는다. 서버 strict 가 꺼져 있으면 평문으로 처리된다.
if (global.console && global.console.warn) {
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
}
return values;
});
}
/**
* 폼의 지정 필드 값을 봉투로 치환한다. 실패해도 reject 하지 않고 평문을 남긴다.
* @param {HTMLFormElement} form
* @param {string[]} fieldNames
* @returns {Promise<void>}
*/
function encryptForm(form, fieldNames) {
if (!form || !fieldNames || fieldNames.length === 0 || !available()) {
return global.Promise.resolve();
}
var targets = {};
var elements = {};
fieldNames.forEach(function (name) {
var el = form.elements ? form.elements[name] : null;
if (el && typeof el.value === 'string' && el.value.length > 0) {
elements[name] = el;
targets[name] = el.value;
}
});
if (Object.keys(targets).length === 0) {
return global.Promise.resolve();
}
return encryptValues(targets).then(function (encrypted) {
Object.keys(elements).forEach(function (name) {
if (encrypted[name]) {
elements[name].value = encrypted[name];
}
});
});
}
// ---------- 선언적 훅 ----------
/**
* <form data-encrypt-fields="password,confirmPassword"> 를 만나면 submit 을 가로채
* 암호화 후 다시 제출한다. form.submit() 을 직접 호출하는 화면(로그인 등)은
* submit 이벤트가 발생하지 않으므로 encryptForm 을 명시적으로 호출해야 한다.
*/
function bindDeclarativeForms() {
if (!available()) {
return;
}
var forms = global.document.querySelectorAll('form[data-encrypt-fields]');
Array.prototype.forEach.call(forms, function (form) {
form.addEventListener('submit', function (event) {
if (form.getAttribute('data-encrypt-done') === 'true') {
form.removeAttribute('data-encrypt-done');
return;
}
// 페이지의 다른 submit 핸들러가 이미 제출을 막았다면(검증 실패 등)
// 여기서 재제출하면 그 검증을 우회하게 된다.
if (event.defaultPrevented) {
return;
}
var names = (form.getAttribute('data-encrypt-fields') || '')
.split(',')
.map(function (name) { return name.trim(); })
.filter(function (name) { return name.length > 0; });
if (names.length === 0) {
return;
}
event.preventDefault();
encryptForm(form, names).then(function () {
form.setAttribute('data-encrypt-done', 'true');
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
});
});
});
}
// DOMContentLoaded 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
if (global.document) {
if (global.document.readyState === 'complete') {
bindDeclarativeForms();
} else {
global.addEventListener('load', bindDeclarativeForms);
}
}
global.portalPasswordCrypto = {
available: available,
encryptForm: encryptForm,
encryptValues: encryptValues
};
})(window);
@@ -16,7 +16,9 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">본인 확인</h2>
<form th:action="@{/auth/stepup/password}" method="post">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
<form th:action="@{/auth/stepup/password}" method="post"
data-encrypt-fields="currentPassword">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
@@ -13,8 +13,9 @@
다시 서비스를 이용하시려면 <span>'장기미사용 제한 해제'</span>를 하셔야 이용하실 수 있습니다.</p>
</div>
<div class="form_type">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
<form id="accountForm" role="form" name="accountForm" th:action="@{/dormant_account/verify}"
method="post">
method="post" data-encrypt-fields="password">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" id="mobileNumber" name="mobileNumber">
<!-- 아이디 입력 -->
@@ -233,7 +233,12 @@
customPopups.showAlert('입력 정보 확인');
} else {
refreshCsrfAndThen(form, function () {
form.submit();
// 비밀번호 전송암호화. 기능이 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면
// 아무 일도 하지 않고 평문 그대로 제출된다(실패해도 reject 하지 않는다).
// form.submit() 은 submit 이벤트를 발생시키지 않아 선언적 훅이 걸리지 않으므로
// 여기서 직접 호출한다.
window.portalPasswordCrypto.encryptForm(form, ['password'])
.then(function () { form.submit(); });
});
}
form.classList.add('was-validated');
@@ -20,7 +20,10 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다(2FA 미요구 경로).
2FA 요구 경로는 아래 스크립트가 preventDefault 하므로 거기서 직접 암호화한다. -->
<form id="passwordChangeForm" th:action="@{/password/change}" method="post"
data-encrypt-fields="newPassword,confirmPassword">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
<div class="info-notice-box">
@@ -127,16 +130,19 @@
if (csrfToken && csrfHeader) {
headers[csrfHeader.content] = csrfToken.content;
}
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
window.portalPasswordCrypto.encryptValues({ password: pw }).then(function (enc) {
$.ajax({
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
method: 'POST',
headers: headers,
data: { password: pw }
data: { password: enc.password }
}).done(function (res) {
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
});
});
}, 300);
});
})();
@@ -160,11 +166,17 @@
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
}
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
// 전송암호화(봉투 치환) 후 제출. 기능이 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
// form.submit() 은 submit 이벤트를 재발생시키지 않아 선언적 훅이 걸리지 않으므로 직접 호출한다.
var encryptThenSubmit = function () {
window.portalPasswordCrypto.encryptForm(form, ['newPassword', 'confirmPassword'])
.then(function () { form.submit(); });
};
if (typeof TwoFactorAuth === 'undefined') { encryptThenSubmit(); return; }
TwoFactorAuth.open({
purpose: '/password/change',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onSuccess: encryptThenSubmit,
onCancel: function () { /* 사용자 취소 — 유지 */ }
});
});
@@ -16,7 +16,10 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/password/verify}" method="post">
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다.
기능이 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 평문 그대로 간다. -->
<form th:action="@{/password/verify}" method="post"
data-encrypt-fields="currentPassword">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="register-form-container">
@@ -390,11 +390,13 @@
return;
}
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
$.ajax({
url: /*[[@{/check_password}]]*/ '/check_password',
type: 'POST',
data: {
password: password,
password: enc.password,
mobileNumber: $('#mobileNumber').val(),
loginId: $('#loginId').val(),
_csrf: $('input[name="_csrf"]').val()
@@ -410,6 +412,7 @@
customPopups.showAlert('처리 중 오류가 발생했습니다. 다시 시도해주세요.');
}
});
});
});
// 비밀번호 확인 검증
@@ -419,12 +422,17 @@
return;
}
// 두 값이 같은 AES 키로 각각 다른 IV 로 암호화되므로 서버에서 복호화 후 비교해야 한다.
window.portalPasswordCrypto.encryptValues({
password: $('#password').val(),
confirmPassword: confirmPassword
}).then(function (enc) {
$.ajax({
url: /*[[@{/check_password_match}]]*/ '/check_password_match',
type: 'POST',
data: {
password: $('#password').val(),
confirmPassword: confirmPassword,
password: enc.password,
confirmPassword: enc.confirmPassword,
_csrf: $('input[name="_csrf"]').val()
},
success: function (response) {
@@ -438,6 +446,7 @@
customPopups.showAlert("처리 중 오류가 발생했습니다. 다시 시도해주세요.");
}
});
});
});
});
</script>
@@ -466,7 +466,10 @@
console.log('Form validation passed, collecting form data...');
const form = collectFormData();
document.body.appendChild(form);
form.submit();
// 전송암호화(봉투 치환) 후 제출. 시나리오별로 password 가 없을 수 있으나
// 존재하는 필드만 치환하므로 그대로 호출한다.
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
.then(function () { form.submit(); });
} else {
console.log('Form validation failed');
@@ -185,7 +185,9 @@
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
form.appendChild(authCompletedField);
form.submit();
// 전송암호화(봉투 치환) 후 제출. 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
.then(function () { form.submit(); });
} else {
customPopups.showAlert(!isAuthVerified
? '인증번호확인을 완료해주세요.'
@@ -166,10 +166,13 @@
function post(url, password) {
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
headers[CSRF_HEADER] = CSRF_TOKEN;
return fetch(url, {
method: 'POST',
headers: headers,
body: 'password=' + encodeURIComponent(password)
// 전송암호화. 꺼져 있거나 브라우저가 지원하지 못하면 원본 값이 그대로 돌아온다.
return window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
return fetch(url, {
method: 'POST',
headers: headers,
body: 'password=' + encodeURIComponent(enc.password)
});
}).then(function (r) { return r.json(); });
}
@@ -32,6 +32,16 @@
};
</script>
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 브라우저가 Web Crypto 를
쓸 수 없으면(로컬 HTTP 등) 모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다. -->
<script th:inline="javascript">
window.__PASSWORD_CRYPTO__ = {
enabled: /*[[${passwordCryptoEnabled}]]*/ false,
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json'
};
</script>
<script th:src="@{/js/password-crypto.js}"></script>
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>