From 59c379b22cd3f5ae7ed81d34f7fd87caa071f4f2 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Tue, 1 Sep 2026 15:06:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=95=94=ED=98=B8=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80:=20-=20RSA-OAEP=20+=20AES-GCM=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=95=94=ED=98=B8=ED=99=94=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EA=B5=AC=ED=98=84=20-=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B3=B5=EA=B0=9C=ED=82=A4=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=B0=8F=20=EB=B3=B5=ED=98=B8=ED=99=94=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?-=20=EC=95=94=ED=98=B8=ED=99=94=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EB=B0=8F=20=EC=9A=94=EC=B2=AD=EB=B3=84/?= =?UTF-8?q?=EC=84=B8=EC=85=98=EB=B3=84/=EC=84=9C=EB=B2=84=EB=B3=84=20?= =?UTF-8?q?=ED=82=A4=20=EB=B3=B4=EA=B4=80=EC=86=8C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../breadcrumb/GlobalControllerAdvice.java | 14 + .../PortalGlobalExceptionHandler.java | 9 +- .../DecryptingRequestWrapper.java | 148 +++++++++++ .../security/passwordcrypto/IssuedKey.java | 21 ++ .../PasswordCryptoProperties.java | 52 ++++ .../PasswordDecryptException.java | 18 ++ .../passwordcrypto/PasswordDecryptFilter.java | 61 +++++ .../passwordcrypto/PasswordEnvelopeCodec.java | 104 ++++++++ .../passwordcrypto/PasswordKeyController.java | 57 ++++ .../passwordcrypto/PasswordKeyPairs.java | 52 ++++ .../passwordcrypto/PasswordKeyStore.java | 20 ++ .../PasswordKeyStoreRouter.java | 57 ++++ .../passwordcrypto/PasswordParamNames.java | 22 ++ .../RequestScopedPasswordKeyStore.java | 90 +++++++ .../ServerScopedPasswordKeyStore.java | 81 ++++++ .../SessionScopedPasswordKeyStore.java | 84 ++++++ .../portal/common/util/StringMaskingUtil.java | 44 +++ .../portal/config/PortalConfigSecurity.java | 15 ++ src/main/resources/application-dev.yml | 4 + src/main/resources/application-prod.yml | 10 +- src/main/resources/application-stage.yml | 4 + src/main/resources/application.yml | 13 + .../resources/static/js/password-crypto.js | 251 ++++++++++++++++++ .../views/apps/auth/stepupPassword.html | 4 +- .../apps/login/inactiveAccountRecovery.html | 3 +- .../templates/views/apps/login/login.html | 7 +- .../views/apps/mypage/passwordChange.html | 22 +- .../apps/mypage/passwordChangeEntry.html | 5 +- .../register/components/newUserInfoForm.html | 15 +- .../views/apps/register/orgUserRegister.html | 5 +- .../views/apps/register/userRegister.html | 4 +- .../views/apps/webhook/webhookList.html | 11 +- .../templates/views/fragment/djbank/head.html | 10 + 33 files changed, 1292 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/IssuedKey.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordCryptoProperties.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptException.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordEnvelopeCodec.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyController.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyPairs.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStore.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStoreRouter.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordParamNames.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/RequestScopedPasswordKeyStore.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/ServerScopedPasswordKeyStore.java create mode 100644 src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/SessionScopedPasswordKeyStore.java create mode 100644 src/main/resources/static/js/password-crypto.js diff --git a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java index be69738..5efa5c2 100644 --- a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java +++ b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java @@ -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 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). */ diff --git a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java index d06550b..354a36f 100644 --- a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java +++ b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java @@ -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); diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java new file mode 100644 index 0000000..a9c4e98 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/DecryptingRequestWrapper.java @@ -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) 형식으로 들어온 파라미터 값을 평문으로 되돌리는 요청 래퍼. + * + *

지연 복호화가 필수다. 생성자에서 {@code getParameterMap()} 을 부르면 컨테이너가 요청 본문을 + * 파싱해버려, 본문을 직접 읽는 필터({@code ApiTesterFilter})가 이후 {@code getInputStream()} 을 못 쓴다. + * 따라서 값은 {@code getParameter*} 호출 시점에만 건드린다.

+ * + *

Lucy XSS 필터({@code order = MIN_VALUE + 1})보다 에서 이 래퍼가 씌워지므로, + * 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거친다. 순서가 뒤바뀌면 특수문자가 든 비밀번호의 + * 이스케이프 여부가 달라져 기존 계정 로그인이 깨진다.

+ */ +@Slf4j +public class DecryptingRequestWrapper extends HttpServletRequestWrapper { + + private final PasswordEnvelopeCodec codec; + private final PasswordKeyStore keyStore; + private final PasswordCryptoProperties properties; + + /** 봉투 원문 → 평문. 같은 값이 여러 번 조회돼도 RSA 연산은 한 번만 한다. */ + private final Map decrypted = new HashMap<>(); + + /** 요청 처리 후 폐기할 keyId(1회용 키 모드). */ + private final Set usedKeyIds = new LinkedHashSet<>(); + + private Map 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 getParameterMap() { + if (parameterMapCache == null) { + Map source = super.getParameterMap(); + Map result = new HashMap<>(Math.max(16, source.size() * 2)); + for (Map.Entry 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 ""; + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/IssuedKey.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/IssuedKey.java new file mode 100644 index 0000000..14064c4 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/IssuedKey.java @@ -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; +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordCryptoProperties.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordCryptoProperties.java new file mode 100644 index 0000000..9f50fc8 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordCryptoProperties.java @@ -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.*} + * + *

브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막기 위한 기능이다. + * 로컬 개발환경은 HTTP(비 secure context)라 브라우저 {@code crypto.subtle} 을 쓸 수 없으므로 + * {@link #enabled} 를 꺼둔다. 켜져 있어도 브라우저가 지원하지 못하면 클라이언트가 평문으로 폴백한다.

+ * + *

이 기능은 XSS 방어가 아니다. 스크립트가 주입되면 입력창에서 직접 탈취할 수 있다. + * "전송 페이로드 평문 노출" 점검 지적에 대한 대응 범위로만 이해할 것.

+ */ +@Data +@Component +@ConfigurationProperties(prefix = "portal.security.password-encrypt") +public class PasswordCryptoProperties { + + /** + * RSA 개인키 보관 범위. + * + *
    + *
  • {@code REQUEST} - 폼 진입마다 1회용 키를 발급하고 요청 1회 사용 후 폐기. 재전송 공격 차단이 가장 강하다.
  • + *
  • {@code SESSION} - 세션 단위로 키를 보관. 세션 복제 환경에 유리하다.
  • + *
  • {@code SERVER} - 서버 고정 키쌍 + TTL 로테이션. 무상태라 가장 단순하지만 재전송 방지 수단이 없다.
  • + *
+ */ + 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; +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptException.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptException.java new file mode 100644 index 0000000..11f7d30 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptException.java @@ -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); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java new file mode 100644 index 0000000..7decae9 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordDecryptFilter.java @@ -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) 파라미터를 평문으로 되돌리는 서블릿 필터. + * + *

{@code PortalConfigSecurity} 에서 order {@code Integer.MIN_VALUE} 로 등록해 + * Lucy XSS 필터({@code MIN_VALUE + 1})보다 먼저 실행된다. 자세한 이유는 + * {@link DecryptingRequestWrapper} 주석 참고.

+ * + *

기능이 꺼져 있거나 POST 가 아니면 아무것도 하지 않는다. 래퍼는 파라미터를 조회할 때만 + * 복호화하므로, 봉투가 없는 요청에는 사실상 비용이 없다.

+ */ +@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(); + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordEnvelopeCodec.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordEnvelopeCodec.java new file mode 100644 index 0000000..2f47750 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordEnvelopeCodec.java @@ -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) 문자열의 파싱·복호화. + * + *
+ * ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(AES-GCM 암호문+태그)>
+ * 
+ * + *

세그먼트는 base64url(패딩 없음)이라 Lucy XSS 이스케이프와 urlencode 를 모두 통과해도 + * 값이 변형되지 않는다.

+ * + *

RSA-OAEP 는 반드시 MGF1 해시까지 SHA-256 으로 지정해야 한다. SunJCE 는 + * {@code OAEPWithSHA-256AndMGF1Padding} 만 지정하면 MGF1 에 SHA-1 을 쓰는데, + * 브라우저 Web Crypto 의 {@code RSA-OAEP + SHA-256} 은 MGF1 도 SHA-256 이라 그대로 두면 + * 복호화가 실패한다.

+ */ +@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; + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyController.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyController.java new file mode 100644 index 0000000..b275290 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyController.java @@ -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; + +/** + * 비밀번호 전송암호화용 공개키 발급. + * + *

경로를 {@code /api/**} 아래에 둔 이유: {@code PortalConfigWebDispatcherServlet#addInterceptors} + * 가 {@code /api/**} 를 인터셉터(비밀번호 변경 강제·step-up 가드)에서 제외한다. 로그인 전 익명 상태에서 + * 호출되므로 {@code /api/session/csrf} 와 같은 자리에 있어야 리다이렉트에 걸리지 않는다.

+ * + *

GET 이라 CSRF 대상이 아니고, 공개키만 나가므로 인증도 요구하지 않는다.

+ */ +@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> issue(HttpServletRequest request) { + if (!properties.isEnabled()) { + // 클라이언트가 조용히 평문 폴백하도록 200 + enabled:false 로 답한다. + return noStore().body(Collections.singletonMap("enabled", Boolean.FALSE)); + } + + IssuedKey key = keyStore.issue(request); + Map 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()); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyPairs.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyPairs.java new file mode 100644 index 0000000..5b04f3e --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyPairs.java @@ -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); + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStore.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStore.java new file mode 100644 index 0000000..6903288 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStore.java @@ -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); +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStoreRouter.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStoreRouter.java new file mode 100644 index 0000000..af588a0 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordKeyStoreRouter.java @@ -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; + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordParamNames.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordParamNames.java new file mode 100644 index 0000000..34f399d --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/PasswordParamNames.java @@ -0,0 +1,22 @@ +package com.eactive.apim.portal.common.security.passwordcrypto; + +/** + * 비밀번호 계열 파라미터명 판별. {@code strict} 모드에서 "평문으로 오면 안 되는 파라미터"를 가리는 데 쓴다. + * + *

실제 사용 중인 이름은 {@code password}, {@code password2}, {@code confirmPassword}, + * {@code newPassword}, {@code currentPassword}, {@code inputPassword} 로 모두 "password" 를 포함한다. + * 향후 축약형이 생길 것을 대비해 {@code passwd}/{@code pwd} 도 함께 본다.

+ */ +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"); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/RequestScopedPasswordKeyStore.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/RequestScopedPasswordKeyStore.java new file mode 100644 index 0000000..4b3c1a8 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/RequestScopedPasswordKeyStore.java @@ -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}). + * + *

같은 keyId 로 두 번 복호화할 수 없으므로 재전송(replay) 공격이 차단된다. 개인키가 인스턴스 + * 로컬 메모리에만 있으므로 WebLogic 다중 인스턴스에서는 스티키 세션이 전제다(현재 CSRF·단일세션 + * 강제가 이미 세션 고정을 전제한다).

+ */ +@Slf4j +@Component +public class RequestScopedPasswordKeyStore implements PasswordKeyStore { + + /** 메모리 폭주 방지 상한. 초과 시 만료 스윕 후에도 남으면 발급을 거절하지 않고 가장 오래된 것부터 버린다. */ + private static final int MAX_ENTRIES = 20_000; + + private final PasswordCryptoProperties properties; + private final Map 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> 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> 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; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/ServerScopedPasswordKeyStore.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/ServerScopedPasswordKeyStore.java new file mode 100644 index 0000000..57f4343 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/ServerScopedPasswordKeyStore.java @@ -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}). + * + *

직전 키를 1개 유예 보관해, 로테이션 순간에 이미 공개키를 받아 간 폼이 제출돼도 복호화된다. + * 재전송 방지 수단은 없으므로(같은 봉투를 여러 번 보내도 복호화됨) 인증 실패횟수 제한·계정 잠금에 + * 의존한다. 무상태라 다중 인스턴스에서도 각자 동작하지만, 인스턴스마다 키가 달라 + * 공개키 발급과 폼 제출이 같은 인스턴스로 가야 한다.

+ */ +@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; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/SessionScopedPasswordKeyStore.java b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/SessionScopedPasswordKeyStore.java new file mode 100644 index 0000000..d6019fb --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/passwordcrypto/SessionScopedPasswordKeyStore.java @@ -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}). + * + *

개인키를 객체가 아니라 PKCS#8 바이트로 들고 있어 세션 복제(직렬화)에도 안전하다. + * 같은 keyId 가 세션 수명 동안 재사용되므로 재전송 방지는 IV 랜덤성과 애플리케이션의 + * 인증 실패횟수 제한에 의존한다.

+ */ +@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; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/util/StringMaskingUtil.java b/src/main/java/com/eactive/apim/portal/common/util/StringMaskingUtil.java index 0a0767e..21d4230 100644 --- a/src/main/java/com/eactive/apim/portal/common/util/StringMaskingUtil.java +++ b/src/main/java/com/eactive/apim/portal/common/util/StringMaskingUtil.java @@ -172,6 +172,50 @@ public class StringMaskingUtil { return value; } + /** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */ + private static final String[] SENSITIVE_PARAM_KEYWORDS = { + "password", "passwd", "pwd", "secret", "credential", "token"}; + + /** + * 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다. + * + *

비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로, + * 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.

+ * + *
id=[user@a.com], password=[****]
+ */ + public static String maskParameterMap(java.util.Map parameterMap) { + if (parameterMap == null || parameterMap.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (java.util.Map.Entry 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 SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList( "client_secret", "clientsecret", "secret", "password", "passwd", "pwd", diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index 341d54a..e39303f 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -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)보다 먼저 실행되어야 한다. + * 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 특수문자가 든 비밀번호의 해시 비교 결과가 + * 지금과 동일하게 유지된다. 순서를 뒤집으면 기존 계정 로그인이 깨진다. + */ + @Bean + public FilterRegistrationBean passwordDecryptFilterRegistration( + PasswordDecryptFilter filter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(filter); + registrationBean.setOrder(Integer.MIN_VALUE); + registrationBean.addUrlPatterns("/*"); + return registrationBean; + } + @Bean public FilterRegistrationBean xssFilterRegistrationBean() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index df20a8c..788a6c6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -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 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index c4dbce4..39ce1f7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -36,4 +36,12 @@ server: # 앞단 프록시(OHS 등) 경유 환경. ForwardedHeaderFilter 가 scheme/host/port/remoteAddr 을 교정한다. # 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다 # (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함). - forward-headers-strategy: framework \ No newline at end of file + forward-headers-strategy: framework + +portal: + security: + # 비밀번호 전송암호화. HTTPS 구간이라 켠다. + # strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가). + password-encrypt: + enabled: true + strict: false diff --git a/src/main/resources/application-stage.yml b/src/main/resources/application-stage.yml index ff3de3b..b2bb0d4 100644 --- a/src/main/resources/application-stage.yml +++ b/src/main/resources/application-stage.yml @@ -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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2c3b8cc..70be512 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/static/js/password-crypto.js b/src/main/resources/static/js/password-crypto.js new file mode 100644 index 0000000..c1eac21 --- /dev/null +++ b/src/main/resources/static/js/password-crypto.js @@ -0,0 +1,251 @@ +/** + * 비밀번호 전송암호화 (RSA-OAEP + AES-GCM 하이브리드). + * + * 브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막는다. + * 서버 공개키로 AES-256-GCM 키를 감싸고, 비밀번호는 그 AES 키로 암호화해 아래 봉투로 보낸다. + * + * ENC1.... + * + * 서버(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} 이름 → 봉투 문자열 + */ + 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} + */ + 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]; + } + }); + }); + } + + // ---------- 선언적 훅 ---------- + + /** + *
를 만나면 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); diff --git a/src/main/resources/templates/views/apps/auth/stepupPassword.html b/src/main/resources/templates/views/apps/auth/stepupPassword.html index 2f47050..ebae22e 100644 --- a/src/main/resources/templates/views/apps/auth/stepupPassword.html +++ b/src/main/resources/templates/views/apps/auth/stepupPassword.html @@ -16,7 +16,9 @@

본인 확인

- + + diff --git a/src/main/resources/templates/views/apps/login/inactiveAccountRecovery.html b/src/main/resources/templates/views/apps/login/inactiveAccountRecovery.html index 0009590..f65a9ca 100644 --- a/src/main/resources/templates/views/apps/login/inactiveAccountRecovery.html +++ b/src/main/resources/templates/views/apps/login/inactiveAccountRecovery.html @@ -13,8 +13,9 @@ 다시 서비스를 이용하시려면 '장기미사용 제한 해제'를 하셔야 이용하실 수 있습니다.

+ + method="post" data-encrypt-fields="password"> diff --git a/src/main/resources/templates/views/apps/login/login.html b/src/main/resources/templates/views/apps/login/login.html index 6e091e7..962d0c7 100644 --- a/src/main/resources/templates/views/apps/login/login.html +++ b/src/main/resources/templates/views/apps/login/login.html @@ -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'); diff --git a/src/main/resources/templates/views/apps/mypage/passwordChange.html b/src/main/resources/templates/views/apps/mypage/passwordChange.html index 9bf845e..1107f82 100644 --- a/src/main/resources/templates/views/apps/mypage/passwordChange.html +++ b/src/main/resources/templates/views/apps/mypage/passwordChange.html @@ -20,7 +20,10 @@

비밀번호 변경

- + +
@@ -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 () { /* 사용자 취소 — 유지 */ } }); }); diff --git a/src/main/resources/templates/views/apps/mypage/passwordChangeEntry.html b/src/main/resources/templates/views/apps/mypage/passwordChangeEntry.html index d8f0a58..e244840 100644 --- a/src/main/resources/templates/views/apps/mypage/passwordChangeEntry.html +++ b/src/main/resources/templates/views/apps/mypage/passwordChangeEntry.html @@ -16,7 +16,10 @@

비밀번호 변경

- + +
diff --git a/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html b/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html index 5e8cca2..0992d6c 100644 --- a/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html +++ b/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html @@ -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("처리 중 오류가 발생했습니다. 다시 시도해주세요."); } }); + }); }); }); diff --git a/src/main/resources/templates/views/apps/register/orgUserRegister.html b/src/main/resources/templates/views/apps/register/orgUserRegister.html index 0a7f8c9..8ea24dd 100644 --- a/src/main/resources/templates/views/apps/register/orgUserRegister.html +++ b/src/main/resources/templates/views/apps/register/orgUserRegister.html @@ -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'); diff --git a/src/main/resources/templates/views/apps/register/userRegister.html b/src/main/resources/templates/views/apps/register/userRegister.html index 6997b82..002ddfc 100644 --- a/src/main/resources/templates/views/apps/register/userRegister.html +++ b/src/main/resources/templates/views/apps/register/userRegister.html @@ -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 ? '인증번호확인을 완료해주세요.' diff --git a/src/main/resources/templates/views/apps/webhook/webhookList.html b/src/main/resources/templates/views/apps/webhook/webhookList.html index 906c929..2b714b4 100644 --- a/src/main/resources/templates/views/apps/webhook/webhookList.html +++ b/src/main/resources/templates/views/apps/webhook/webhookList.html @@ -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(); }); } diff --git a/src/main/resources/templates/views/fragment/djbank/head.html b/src/main/resources/templates/views/fragment/djbank/head.html index f37fca9..dc78457 100644 --- a/src/main/resources/templates/views/fragment/djbank/head.html +++ b/src/main/resources/templates/views/fragment/djbank/head.html @@ -32,6 +32,16 @@ }; + + + +