feats/security 브랜치 병합 - 비밀번호 전송 암호화(RSA-OAEP + AES-GCM) 도입
This commit is contained in:
@@ -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.RelatedSite;
|
||||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
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;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
@@ -51,6 +52,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private Environment environment;
|
private Environment environment;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordCryptoProperties passwordCryptoProperties;
|
||||||
|
|
||||||
@ModelAttribute("breadcrumb")
|
@ModelAttribute("breadcrumb")
|
||||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||||
String currentPath = request.getRequestURI();
|
String currentPath = request.getRequestURI();
|
||||||
@@ -125,6 +129,16 @@ public class GlobalControllerAdvice {
|
|||||||
return passwordPolicyProperties.isKeyboardSequenceBlocked();
|
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).
|
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+3
-6
@@ -1,9 +1,7 @@
|
|||||||
package com.eactive.apim.portal.common.exception;
|
package com.eactive.apim.portal.common.exception;
|
||||||
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||||
@@ -195,10 +193,9 @@ public class PortalGlobalExceptionHandler {
|
|||||||
*/
|
*/
|
||||||
@ExceptionHandler(value = Exception.class)
|
@ExceptionHandler(value = Exception.class)
|
||||||
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
||||||
String requestParams = request.getParameterMap().entrySet()
|
// 비밀번호·시크릿·토큰 계열 파라미터는 값을 가린다. 전송암호화가 켜져 있어도
|
||||||
.stream()
|
// 이 시점의 파라미터는 이미 복호화된 평문이다.
|
||||||
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
|
|
||||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||||
|
|
||||||
|
|||||||
+148
@@ -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;
|
||||||
|
}
|
||||||
+52
@@ -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;
|
||||||
|
}
|
||||||
+18
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(AES-GCM 암호문+태그)>
|
||||||
|
* </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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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);
|
||||||
|
}
|
||||||
+57
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -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;
|
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) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
||||||
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
||||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
"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.apps.session.filter.SessionValidationFilter;
|
||||||
|
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
|
||||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
@@ -64,6 +65,20 @@ public class PortalConfigSecurity {
|
|||||||
return registration;
|
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
|
@Bean
|
||||||
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
||||||
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ gateway:
|
|||||||
|
|
||||||
portal:
|
portal:
|
||||||
# auth-virtual-code: 654321
|
# auth-virtual-code: 654321
|
||||||
|
security:
|
||||||
|
# 개발 서버는 HTTPS 가 아니라 브라우저 Web Crypto 를 쓸 수 없다.
|
||||||
|
password-encrypt:
|
||||||
|
enabled: false
|
||||||
dev:
|
dev:
|
||||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||||
hot-reload-pages: true
|
hot-reload-pages: true
|
||||||
|
|||||||
@@ -37,3 +37,11 @@ server:
|
|||||||
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
||||||
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
||||||
forward-headers-strategy: framework
|
forward-headers-strategy: framework
|
||||||
|
|
||||||
|
portal:
|
||||||
|
security:
|
||||||
|
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
|
||||||
|
# strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가).
|
||||||
|
password-encrypt:
|
||||||
|
enabled: true
|
||||||
|
strict: false
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ spring:
|
|||||||
portal:
|
portal:
|
||||||
# 검증 단계에선 사용하지 않음
|
# 검증 단계에선 사용하지 않음
|
||||||
# auth-virtual-code: 654321
|
# auth-virtual-code: 654321
|
||||||
|
security:
|
||||||
|
# HTTPS 구간이라 전송암호화를 켠다. strict 는 운영 승격 전 검증 후 판단.
|
||||||
|
password-encrypt:
|
||||||
|
enabled: true
|
||||||
dev:
|
dev:
|
||||||
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
|
||||||
hot-reload-pages: true
|
hot-reload-pages: true
|
||||||
|
|||||||
@@ -153,6 +153,19 @@ portal:
|
|||||||
user-approval: true
|
user-approval: true
|
||||||
password-expiration-days: 90
|
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:
|
pages:
|
||||||
- path-pattern: /dashboard/daily_usage
|
- path-pattern: /dashboard/daily_usage
|
||||||
method: GET
|
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">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">본인 확인</h2>
|
<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" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
|
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
다시 서비스를 이용하시려면 <span>'장기미사용 제한 해제'</span>를 하셔야 이용하실 수 있습니다.</p>
|
다시 서비스를 이용하시려면 <span>'장기미사용 제한 해제'</span>를 하셔야 이용하실 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form_type">
|
<div class="form_type">
|
||||||
|
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
|
||||||
<form id="accountForm" role="form" name="accountForm" th:action="@{/dormant_account/verify}"
|
<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" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||||
<input type="hidden" id="mobileNumber" name="mobileNumber">
|
<input type="hidden" id="mobileNumber" name="mobileNumber">
|
||||||
<!-- 아이디 입력 -->
|
<!-- 아이디 입력 -->
|
||||||
|
|||||||
@@ -233,7 +233,12 @@
|
|||||||
customPopups.showAlert('입력 정보 확인');
|
customPopups.showAlert('입력 정보 확인');
|
||||||
} else {
|
} else {
|
||||||
refreshCsrfAndThen(form, function () {
|
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');
|
form.classList.add('was-validated');
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
<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}" />
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
<div class="register-form-container">
|
<div class="register-form-container">
|
||||||
<div class="info-notice-box">
|
<div class="info-notice-box">
|
||||||
@@ -127,16 +130,19 @@
|
|||||||
if (csrfToken && csrfHeader) {
|
if (csrfToken && csrfHeader) {
|
||||||
headers[csrfHeader.content] = csrfToken.content;
|
headers[csrfHeader.content] = csrfToken.content;
|
||||||
}
|
}
|
||||||
|
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
|
||||||
|
window.portalPasswordCrypto.encryptValues({ password: pw }).then(function (enc) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
|
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
data: { password: pw }
|
data: { password: enc.password }
|
||||||
}).done(function (res) {
|
}).done(function (res) {
|
||||||
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
|
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
|
||||||
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
|
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
|
||||||
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
|
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -160,11 +166,17 @@
|
|||||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
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({
|
TwoFactorAuth.open({
|
||||||
purpose: '/password/change',
|
purpose: '/password/change',
|
||||||
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
onSuccess: encryptThenSubmit,
|
||||||
onSuccess: function () { form.submit(); },
|
|
||||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
<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}" />
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
|
|
||||||
<div class="register-form-container">
|
<div class="register-form-container">
|
||||||
|
|||||||
@@ -390,11 +390,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
|
||||||
|
window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/check_password}]]*/ '/check_password',
|
url: /*[[@{/check_password}]]*/ '/check_password',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: {
|
||||||
password: password,
|
password: enc.password,
|
||||||
mobileNumber: $('#mobileNumber').val(),
|
mobileNumber: $('#mobileNumber').val(),
|
||||||
loginId: $('#loginId').val(),
|
loginId: $('#loginId').val(),
|
||||||
_csrf: $('input[name="_csrf"]').val()
|
_csrf: $('input[name="_csrf"]').val()
|
||||||
@@ -411,6 +413,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 비밀번호 확인 검증
|
// 비밀번호 확인 검증
|
||||||
$('#confirmPassword').on('blur', function () {
|
$('#confirmPassword').on('blur', function () {
|
||||||
@@ -419,12 +422,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 두 값이 같은 AES 키로 각각 다른 IV 로 암호화되므로 서버에서 복호화 후 비교해야 한다.
|
||||||
|
window.portalPasswordCrypto.encryptValues({
|
||||||
|
password: $('#password').val(),
|
||||||
|
confirmPassword: confirmPassword
|
||||||
|
}).then(function (enc) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: /*[[@{/check_password_match}]]*/ '/check_password_match',
|
url: /*[[@{/check_password_match}]]*/ '/check_password_match',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: {
|
||||||
password: $('#password').val(),
|
password: enc.password,
|
||||||
confirmPassword: confirmPassword,
|
confirmPassword: enc.confirmPassword,
|
||||||
_csrf: $('input[name="_csrf"]').val()
|
_csrf: $('input[name="_csrf"]').val()
|
||||||
},
|
},
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
@@ -440,6 +448,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
|||||||
@@ -466,7 +466,10 @@
|
|||||||
console.log('Form validation passed, collecting form data...');
|
console.log('Form validation passed, collecting form data...');
|
||||||
const form = collectFormData();
|
const form = collectFormData();
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
// 전송암호화(봉투 치환) 후 제출. 시나리오별로 password 가 없을 수 있으나
|
||||||
|
// 존재하는 필드만 치환하므로 그대로 호출한다.
|
||||||
|
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
||||||
|
.then(function () { form.submit(); });
|
||||||
} else {
|
} else {
|
||||||
console.log('Form validation failed');
|
console.log('Form validation failed');
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,9 @@
|
|||||||
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
|
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
|
||||||
form.appendChild(authCompletedField);
|
form.appendChild(authCompletedField);
|
||||||
|
|
||||||
form.submit();
|
// 전송암호화(봉투 치환) 후 제출. 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
|
||||||
|
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
||||||
|
.then(function () { form.submit(); });
|
||||||
} else {
|
} else {
|
||||||
customPopups.showAlert(!isAuthVerified
|
customPopups.showAlert(!isAuthVerified
|
||||||
? '인증번호확인을 완료해주세요.'
|
? '인증번호확인을 완료해주세요.'
|
||||||
|
|||||||
@@ -166,10 +166,13 @@
|
|||||||
function post(url, password) {
|
function post(url, password) {
|
||||||
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||||
headers[CSRF_HEADER] = CSRF_TOKEN;
|
headers[CSRF_HEADER] = CSRF_TOKEN;
|
||||||
|
// 전송암호화. 꺼져 있거나 브라우저가 지원하지 못하면 원본 값이 그대로 돌아온다.
|
||||||
|
return window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: 'password=' + encodeURIComponent(password)
|
body: 'password=' + encodeURIComponent(enc.password)
|
||||||
|
});
|
||||||
}).then(function (r) { return r.json(); });
|
}).then(function (r) { return r.json(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,16 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</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에서 토큰·헤더명을 읽어 사용한다. -->
|
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user