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.RelatedSiteService;
|
||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
||||
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordCryptoProperties;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
|
||||
@ControllerAdvice
|
||||
@@ -51,6 +52,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@Autowired
|
||||
private PasswordCryptoProperties passwordCryptoProperties;
|
||||
|
||||
@ModelAttribute("breadcrumb")
|
||||
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
|
||||
String currentPath = request.getRequestURI();
|
||||
@@ -125,6 +129,16 @@ public class GlobalControllerAdvice {
|
||||
return passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 사용 여부(yml portal.security.password-encrypt.enabled).
|
||||
* head 의 window.__PASSWORD_CRYPTO__ 로 내려가 password-crypto.js 가 읽는다.
|
||||
* 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 화면은 평문으로 폴백한다.
|
||||
*/
|
||||
@ModelAttribute("passwordCryptoEnabled")
|
||||
public boolean passwordCryptoEnabled() {
|
||||
return passwordCryptoProperties.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||
*/
|
||||
|
||||
+3
-6
@@ -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);
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
/** 요청 파라미터 로깅 시 값을 통째로 가릴 키. 부분일치(소문자)로 본다. */
|
||||
private static final String[] SENSITIVE_PARAM_KEYWORDS = {
|
||||
"password", "passwd", "pwd", "secret", "credential", "token"};
|
||||
|
||||
/**
|
||||
* 요청 파라미터 맵을 로그용 문자열로 만든다. 비밀번호·시크릿·토큰 계열 키는 값을 {@code [****]} 로 가린다.
|
||||
*
|
||||
* <p>비밀번호 전송암호화가 켜져 있어도 이 시점의 파라미터는 이미 복호화된 평문이므로,
|
||||
* 마스킹 없이 로깅하면 암호화 조치가 무의미해진다.</p>
|
||||
*
|
||||
* <pre>id=[user@a.com], password=[****]</pre>
|
||||
*/
|
||||
public static String maskParameterMap(java.util.Map<String, String[]> parameterMap) {
|
||||
if (parameterMap == null || parameterMap.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (java.util.Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(entry.getKey()).append('=');
|
||||
if (isSensitiveParamName(entry.getKey())) {
|
||||
sb.append("[****]");
|
||||
} else {
|
||||
sb.append(Arrays.toString(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean isSensitiveParamName(String name) {
|
||||
if (!isValidString(name)) {
|
||||
return false;
|
||||
}
|
||||
String lower = name.toLowerCase();
|
||||
for (String keyword : SENSITIVE_PARAM_KEYWORDS) {
|
||||
if (lower.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
||||
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
|
||||
import com.eactive.apim.portal.common.security.passwordcrypto.PasswordDecryptFilter;
|
||||
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
@@ -64,6 +65,20 @@ public class PortalConfigSecurity {
|
||||
return registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 전송암호화 복호화 필터. Lucy XSS 필터(order = MIN_VALUE + 1)보다 <b>먼저</b> 실행되어야 한다.
|
||||
* 복호화된 평문이 기존과 똑같이 XSS 이스케이프를 거쳐야 특수문자가 든 비밀번호의 해시 비교 결과가
|
||||
* 지금과 동일하게 유지된다. 순서를 뒤집으면 기존 계정 로그인이 깨진다.
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<PasswordDecryptFilter> passwordDecryptFilterRegistration(
|
||||
PasswordDecryptFilter filter) {
|
||||
FilterRegistrationBean<PasswordDecryptFilter> registrationBean = new FilterRegistrationBean<>(filter);
|
||||
registrationBean.setOrder(Integer.MIN_VALUE);
|
||||
registrationBean.addUrlPatterns("/*");
|
||||
return registrationBean;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<XssEscapeServletFilter> xssFilterRegistrationBean() {
|
||||
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,3 +37,11 @@ server:
|
||||
# 상세 주석은 application.yml 의 동일 키 참고. /internal/** 은 필터에서 제외된다
|
||||
# (PortalConfigForwardedHeader — 원 소켓 IP 기반 허용 IP 검사를 보존하기 위함).
|
||||
forward-headers-strategy: framework
|
||||
|
||||
portal:
|
||||
security:
|
||||
# 비밀번호 전송암호화. HTTPS 구간이라 켠다.
|
||||
# strict 는 스테이지 검증 후 별도 판단(켜면 JS 비활성 사용자는 로그인 불가).
|
||||
password-encrypt:
|
||||
enabled: true
|
||||
strict: false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,6 +153,19 @@ portal:
|
||||
user-approval: true
|
||||
password-expiration-days: 90
|
||||
|
||||
security:
|
||||
# 비밀번호 전송암호화(RSA-OAEP + AES-GCM). 개발자도구 Network 탭 평문 노출 대응.
|
||||
# 로컬/개발 서버는 HTTP(비 secure context)라 브라우저 crypto.subtle 을 못 쓰므로 기본은 꺼둔다.
|
||||
# 켜져 있어도 브라우저가 지원하지 못하면 화면이 평문으로 폴백한다.
|
||||
password-encrypt:
|
||||
enabled: false
|
||||
# REQUEST(폼당 1회용) | SESSION(세션당) | SERVER(서버 고정 + TTL 로테이션)
|
||||
key-scope: REQUEST
|
||||
key-ttl-seconds: 300
|
||||
# true 면 평문 비밀번호 파라미터를 거부한다. JS 비활성 사용자가 로그인하지 못하므로 안정화 후에만 켠다.
|
||||
strict: false
|
||||
rsa-key-size: 2048
|
||||
|
||||
pages:
|
||||
- path-pattern: /dashboard/daily_usage
|
||||
method: GET
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 비밀번호 전송암호화 (RSA-OAEP + AES-GCM 하이브리드).
|
||||
*
|
||||
* 브라우저 개발자도구 Network 탭에 비밀번호가 평문으로 보이는 것을 막는다.
|
||||
* 서버 공개키로 AES-256-GCM 키를 감싸고, 비밀번호는 그 AES 키로 암호화해 아래 봉투로 보낸다.
|
||||
*
|
||||
* ENC1.<keyId>.<b64url(RSA-OAEP(AES키))>.<b64url(iv 12B)>.<b64url(암호문+태그)>
|
||||
*
|
||||
* 서버(PasswordDecryptFilter)가 파라미터 값을 평문으로 되돌리므로 컨트롤러는 아무것도 몰라도 된다.
|
||||
*
|
||||
* 폴백: 설정이 꺼져 있거나(window.__PASSWORD_CRYPTO__.enabled=false), secure context 가 아니거나
|
||||
* (로컬 HTTP 개발환경), crypto.subtle 이 없으면 아무 일도 하지 않고 평문 그대로 전송한다.
|
||||
* 키 조회·암호화 중 오류가 나도 마찬가지다. 즉 이 모듈은 절대 화면 흐름을 막지 않는다.
|
||||
*
|
||||
* 주의: 이 조치는 XSS 방어가 아니다. 스크립트가 주입되면 입력창에서 직접 값을 가져갈 수 있다.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var cfg = global.__PASSWORD_CRYPTO__ || {};
|
||||
var PREFIX = 'ENC1.';
|
||||
|
||||
function available() {
|
||||
return !!(cfg.enabled
|
||||
&& global.isSecureContext
|
||||
&& global.crypto
|
||||
&& global.crypto.subtle
|
||||
&& global.fetch
|
||||
&& global.Promise);
|
||||
}
|
||||
|
||||
// ---------- 인코딩 유틸 ----------
|
||||
|
||||
function toBase64Url(buffer) {
|
||||
var bytes = new Uint8Array(buffer);
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return global.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64ToBytes(base64) {
|
||||
var binary = global.atob(base64);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ---------- 키 조회 ----------
|
||||
|
||||
/** 재사용 가능한 스코프(SESSION/SERVER)에서만 채워진다. { keyId, publicKey, expiresAt } */
|
||||
var cachedKey = null;
|
||||
|
||||
/**
|
||||
* 서버에서 공개키를 받아 import 한다.
|
||||
*
|
||||
* key-scope 가 REQUEST 면 발급된 키가 1회용이라 매번 새로 받는다. SESSION/SERVER 면 만료 전까지
|
||||
* 캐시해 재사용한다 — 비밀번호 검증용 ajax(입력 중 호출)가 호출마다 서버에서 RSA 키쌍을
|
||||
* 생성하게 만들지 않기 위함이다.
|
||||
*/
|
||||
function loadKey() {
|
||||
if (cachedKey && cachedKey.expiresAt > Date.now()) {
|
||||
return global.Promise.resolve(cachedKey);
|
||||
}
|
||||
return global.fetch(cfg.keyUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('공개키 조회 실패: HTTP ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (data) {
|
||||
if (!data || !data.enabled || !data.keyId || !data.publicKey) {
|
||||
throw new Error('전송암호화 비활성 상태의 응답');
|
||||
}
|
||||
return global.crypto.subtle.importKey(
|
||||
'spki',
|
||||
base64ToBytes(data.publicKey),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['encrypt']
|
||||
).then(function (publicKey) {
|
||||
var key = { keyId: data.keyId, publicKey: publicKey };
|
||||
if (data.keyScope && data.keyScope !== 'REQUEST') {
|
||||
// 만료 30초 전에는 버려서 경계에서 실패하지 않게 한다.
|
||||
var ttlMs = Math.max(0, (data.expiresIn || 0) - 30) * 1000;
|
||||
if (ttlMs > 0) {
|
||||
key.expiresAt = Date.now() + ttlMs;
|
||||
cachedKey = key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 암호화 ----------
|
||||
|
||||
/**
|
||||
* 값 여러 개를 한 번에 암호화한다. AES 키와 RSA 랩은 1회만 하고 IV 만 값마다 새로 만든다.
|
||||
* @param {Object} values 이름 → 평문
|
||||
* @returns {Promise<Object>} 이름 → 봉투 문자열
|
||||
*/
|
||||
function encryptValues(values) {
|
||||
var names = Object.keys(values || {}).filter(function (name) {
|
||||
var v = values[name];
|
||||
return typeof v === 'string' && v.length > 0 && v.indexOf(PREFIX) !== 0;
|
||||
});
|
||||
if (!available() || names.length === 0) {
|
||||
return global.Promise.resolve(values || {});
|
||||
}
|
||||
|
||||
var subtle = global.crypto.subtle;
|
||||
var context = {};
|
||||
|
||||
return loadKey().then(function (key) {
|
||||
context.keyId = key.keyId;
|
||||
context.publicKey = key.publicKey;
|
||||
return subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
|
||||
}).then(function (aesKey) {
|
||||
context.aesKey = aesKey;
|
||||
return subtle.exportKey('raw', aesKey);
|
||||
}).then(function (rawAesKey) {
|
||||
return subtle.encrypt({ name: 'RSA-OAEP' }, context.publicKey, rawAesKey);
|
||||
}).then(function (wrapped) {
|
||||
context.wrappedKey = toBase64Url(wrapped);
|
||||
return global.Promise.all(names.map(function (name) {
|
||||
var iv = global.crypto.getRandomValues(new Uint8Array(12));
|
||||
return subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv, tagLength: 128 },
|
||||
context.aesKey,
|
||||
new TextEncoder().encode(values[name])
|
||||
).then(function (cipherText) {
|
||||
return {
|
||||
name: name,
|
||||
envelope: PREFIX + context.keyId + '.' + context.wrappedKey
|
||||
+ '.' + toBase64Url(iv) + '.' + toBase64Url(cipherText)
|
||||
};
|
||||
});
|
||||
}));
|
||||
}).then(function (results) {
|
||||
var out = {};
|
||||
Object.keys(values).forEach(function (name) { out[name] = values[name]; });
|
||||
results.forEach(function (result) { out[result.name] = result.envelope; });
|
||||
return out;
|
||||
}).catch(function (error) {
|
||||
// 암호화 실패는 화면을 막지 않는다. 서버 strict 가 꺼져 있으면 평문으로 처리된다.
|
||||
if (global.console && global.console.warn) {
|
||||
global.console.warn('[password-crypto] 평문으로 폴백:', error && error.message);
|
||||
}
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼의 지정 필드 값을 봉투로 치환한다. 실패해도 reject 하지 않고 평문을 남긴다.
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {string[]} fieldNames
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function encryptForm(form, fieldNames) {
|
||||
if (!form || !fieldNames || fieldNames.length === 0 || !available()) {
|
||||
return global.Promise.resolve();
|
||||
}
|
||||
var targets = {};
|
||||
var elements = {};
|
||||
fieldNames.forEach(function (name) {
|
||||
var el = form.elements ? form.elements[name] : null;
|
||||
if (el && typeof el.value === 'string' && el.value.length > 0) {
|
||||
elements[name] = el;
|
||||
targets[name] = el.value;
|
||||
}
|
||||
});
|
||||
if (Object.keys(targets).length === 0) {
|
||||
return global.Promise.resolve();
|
||||
}
|
||||
return encryptValues(targets).then(function (encrypted) {
|
||||
Object.keys(elements).forEach(function (name) {
|
||||
if (encrypted[name]) {
|
||||
elements[name].value = encrypted[name];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 선언적 훅 ----------
|
||||
|
||||
/**
|
||||
* <form data-encrypt-fields="password,confirmPassword"> 를 만나면 submit 을 가로채
|
||||
* 암호화 후 다시 제출한다. form.submit() 을 직접 호출하는 화면(로그인 등)은
|
||||
* submit 이벤트가 발생하지 않으므로 encryptForm 을 명시적으로 호출해야 한다.
|
||||
*/
|
||||
function bindDeclarativeForms() {
|
||||
if (!available()) {
|
||||
return;
|
||||
}
|
||||
var forms = global.document.querySelectorAll('form[data-encrypt-fields]');
|
||||
Array.prototype.forEach.call(forms, function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (form.getAttribute('data-encrypt-done') === 'true') {
|
||||
form.removeAttribute('data-encrypt-done');
|
||||
return;
|
||||
}
|
||||
// 페이지의 다른 submit 핸들러가 이미 제출을 막았다면(검증 실패 등)
|
||||
// 여기서 재제출하면 그 검증을 우회하게 된다.
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
var names = (form.getAttribute('data-encrypt-fields') || '')
|
||||
.split(',')
|
||||
.map(function (name) { return name.trim(); })
|
||||
.filter(function (name) { return name.length > 0; });
|
||||
if (names.length === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
encryptForm(form, names).then(function () {
|
||||
form.setAttribute('data-encrypt-done', 'true');
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// DOMContentLoaded 가 아니라 load 시점에 건다. 페이지의 검증 핸들러는 대부분
|
||||
// DOMContentLoaded/$(function) 에서 등록되므로, 그보다 늦게 등록해야 우리 리스너가 마지막에 실행되어
|
||||
// 앞선 핸들러의 preventDefault(검증 실패)를 정확히 감지할 수 있다.
|
||||
if (global.document) {
|
||||
if (global.document.readyState === 'complete') {
|
||||
bindDeclarativeForms();
|
||||
} else {
|
||||
global.addEventListener('load', bindDeclarativeForms);
|
||||
}
|
||||
}
|
||||
|
||||
global.portalPasswordCrypto = {
|
||||
available: available,
|
||||
encryptForm: encryptForm,
|
||||
encryptValues: encryptValues
|
||||
};
|
||||
})(window);
|
||||
@@ -16,7 +16,9 @@
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">본인 확인</h2>
|
||||
|
||||
<form th:action="@{/auth/stepup/password}" method="post">
|
||||
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
|
||||
<form th:action="@{/auth/stepup/password}" method="post"
|
||||
data-encrypt-fields="currentPassword">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}" />
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
다시 서비스를 이용하시려면 <span>'장기미사용 제한 해제'</span>를 하셔야 이용하실 수 있습니다.</p>
|
||||
</div>
|
||||
<div class="form_type">
|
||||
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
|
||||
<form id="accountForm" role="form" name="accountForm" th:action="@{/dormant_account/verify}"
|
||||
method="post">
|
||||
method="post" data-encrypt-fields="password">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" id="mobileNumber" name="mobileNumber">
|
||||
<!-- 아이디 입력 -->
|
||||
|
||||
@@ -233,7 +233,12 @@
|
||||
customPopups.showAlert('입력 정보 확인');
|
||||
} else {
|
||||
refreshCsrfAndThen(form, function () {
|
||||
form.submit();
|
||||
// 비밀번호 전송암호화. 기능이 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면
|
||||
// 아무 일도 하지 않고 평문 그대로 제출된다(실패해도 reject 하지 않는다).
|
||||
// form.submit() 은 submit 이벤트를 발생시키지 않아 선언적 훅이 걸리지 않으므로
|
||||
// 여기서 직접 호출한다.
|
||||
window.portalPasswordCrypto.encryptForm(form, ['password'])
|
||||
.then(function () { form.submit(); });
|
||||
});
|
||||
}
|
||||
form.classList.add('was-validated');
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||
|
||||
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
|
||||
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다(2FA 미요구 경로).
|
||||
2FA 요구 경로는 아래 스크립트가 preventDefault 하므로 거기서 직접 암호화한다. -->
|
||||
<form id="passwordChangeForm" th:action="@{/password/change}" method="post"
|
||||
data-encrypt-fields="newPassword,confirmPassword">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
@@ -127,16 +130,19 @@
|
||||
if (csrfToken && csrfHeader) {
|
||||
headers[csrfHeader.content] = csrfToken.content;
|
||||
}
|
||||
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
|
||||
window.portalPasswordCrypto.encryptValues({ password: pw }).then(function (enc) {
|
||||
$.ajax({
|
||||
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
data: { password: pw }
|
||||
data: { password: enc.password }
|
||||
}).done(function (res) {
|
||||
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
|
||||
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
|
||||
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
|
||||
});
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
})();
|
||||
@@ -160,11 +166,17 @@
|
||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
||||
}
|
||||
|
||||
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
|
||||
// 전송암호화(봉투 치환) 후 제출. 기능이 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
|
||||
// form.submit() 은 submit 이벤트를 재발생시키지 않아 선언적 훅이 걸리지 않으므로 직접 호출한다.
|
||||
var encryptThenSubmit = function () {
|
||||
window.portalPasswordCrypto.encryptForm(form, ['newPassword', 'confirmPassword'])
|
||||
.then(function () { form.submit(); });
|
||||
};
|
||||
|
||||
if (typeof TwoFactorAuth === 'undefined') { encryptThenSubmit(); return; }
|
||||
TwoFactorAuth.open({
|
||||
purpose: '/password/change',
|
||||
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
||||
onSuccess: function () { form.submit(); },
|
||||
onSuccess: encryptThenSubmit,
|
||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||
|
||||
<form th:action="@{/password/verify}" method="post">
|
||||
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다.
|
||||
기능이 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면 평문 그대로 간다. -->
|
||||
<form th:action="@{/password/verify}" method="post"
|
||||
data-encrypt-fields="currentPassword">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div class="register-form-container">
|
||||
|
||||
@@ -390,11 +390,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// 전송암호화. 꺼져 있으면 원본 값이 그대로 돌아온다.
|
||||
window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
|
||||
$.ajax({
|
||||
url: /*[[@{/check_password}]]*/ '/check_password',
|
||||
type: 'POST',
|
||||
data: {
|
||||
password: password,
|
||||
password: enc.password,
|
||||
mobileNumber: $('#mobileNumber').val(),
|
||||
loginId: $('#loginId').val(),
|
||||
_csrf: $('input[name="_csrf"]').val()
|
||||
@@ -411,6 +413,7 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 비밀번호 확인 검증
|
||||
$('#confirmPassword').on('blur', function () {
|
||||
@@ -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) {
|
||||
@@ -440,6 +448,7 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
|
||||
@@ -466,7 +466,10 @@
|
||||
console.log('Form validation passed, collecting form data...');
|
||||
const form = collectFormData();
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
// 전송암호화(봉투 치환) 후 제출. 시나리오별로 password 가 없을 수 있으나
|
||||
// 존재하는 필드만 치환하므로 그대로 호출한다.
|
||||
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
||||
.then(function () { form.submit(); });
|
||||
} else {
|
||||
console.log('Form validation failed');
|
||||
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
|
||||
form.appendChild(authCompletedField);
|
||||
|
||||
form.submit();
|
||||
// 전송암호화(봉투 치환) 후 제출. 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
|
||||
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
||||
.then(function () { form.submit(); });
|
||||
} else {
|
||||
customPopups.showAlert(!isAuthVerified
|
||||
? '인증번호확인을 완료해주세요.'
|
||||
|
||||
@@ -166,10 +166,13 @@
|
||||
function post(url, password) {
|
||||
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
headers[CSRF_HEADER] = CSRF_TOKEN;
|
||||
// 전송암호화. 꺼져 있거나 브라우저가 지원하지 못하면 원본 값이 그대로 돌아온다.
|
||||
return window.portalPasswordCrypto.encryptValues({ password: password }).then(function (enc) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: 'password=' + encodeURIComponent(password)
|
||||
body: 'password=' + encodeURIComponent(enc.password)
|
||||
});
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,16 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- 비밀번호 전송암호화(RSA-OAEP + AES-GCM) 설정. 기능이 꺼져 있거나 브라우저가 Web Crypto 를
|
||||
쓸 수 없으면(로컬 HTTP 등) 모듈이 평문으로 폴백하므로 스크립트는 항상 로드한다. -->
|
||||
<script th:inline="javascript">
|
||||
window.__PASSWORD_CRYPTO__ = {
|
||||
enabled: /*[[${passwordCryptoEnabled}]]*/ false,
|
||||
keyUrl: /*[[@{/api/security/password-key.json}]]*/ '/api/security/password-key.json'
|
||||
};
|
||||
</script>
|
||||
<script th:src="@{/js/password-crypto.js}"></script>
|
||||
|
||||
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||
|
||||
Reference in New Issue
Block a user