Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11f1dae1f3 | |||
| d275aa12c4 |
@@ -1,18 +1,28 @@
|
|||||||
package com.eactive.apim.portal.apps.app.service;
|
package com.eactive.apim.portal.apps.app.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
|
||||||
*
|
*
|
||||||
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
|
||||||
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
|
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
|
||||||
|
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
|
||||||
|
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
|
||||||
|
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -22,6 +32,8 @@ public class AdminGatewayClient {
|
|||||||
private static final String PROP_GROUP = "Portal";
|
private static final String PROP_GROUP = "Portal";
|
||||||
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
|
||||||
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
|
||||||
|
private static final String PROP_ADMIN_BASE_URL_DESC =
|
||||||
|
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
|
||||||
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
|
|||||||
/**
|
/**
|
||||||
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
|
||||||
*
|
*
|
||||||
|
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
|
||||||
|
*
|
||||||
* @param clientId 차단할 클라이언트 ID
|
* @param clientId 차단할 클라이언트 ID
|
||||||
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
|
* @throws RuntimeException admin 미응답/네트워크 오류(전 노드 실패) 또는 admin 처리 실패 시 (호출측에서 처리)
|
||||||
*/
|
*/
|
||||||
public void blockClient(String clientId) {
|
public void blockClient(String clientId) {
|
||||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
List<String> baseUrls = resolveBaseUrls();
|
||||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
|
||||||
|
|
||||||
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
RestClientException lastError = null;
|
||||||
|
for (int i = 0; i < baseUrls.size(); i++) {
|
||||||
|
String baseUrl = baseUrls.get(i);
|
||||||
|
String url = baseUrl + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
ResponseEntity<Map> response;
|
||||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
try {
|
||||||
|
response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
// 통신 계층 실패 — 다음 admin 주소로 failover
|
||||||
|
lastError = e;
|
||||||
|
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
|
||||||
|
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
|
||||||
Map<?, ?> body = response.getBody();
|
Map<?, ?> body = response.getBody();
|
||||||
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
|
||||||
if (!success) {
|
if (!success) {
|
||||||
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
|
||||||
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
|
||||||
|
*
|
||||||
|
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
|
||||||
|
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
|
||||||
|
*/
|
||||||
|
private List<String> resolveBaseUrls() {
|
||||||
|
String raw = portalPropertyService.getOrCreateProperty(
|
||||||
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
|
||||||
|
|
||||||
|
List<String> urls = parseBaseUrls(raw);
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
|
||||||
|
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
|
||||||
|
*/
|
||||||
|
static List<String> parseBaseUrls(String raw) {
|
||||||
|
Set<String> ordered = new LinkedHashSet<>();
|
||||||
|
if (raw != null) {
|
||||||
|
for (String token : raw.split("[,\\r\\n]")) {
|
||||||
|
String url = stripTrailingSlashes(token.trim());
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
ordered.add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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
|
||||||
@@ -52,9 +51,6 @@ 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();
|
||||||
@@ -129,16 +125,6 @@ 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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+6
-3
@@ -1,7 +1,9 @@
|
|||||||
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;
|
||||||
@@ -193,9 +195,10 @@ 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()
|
||||||
String requestParams = StringMaskingUtil.maskParameterMap(request.getParameterMap());
|
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
|
||||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||||
|
|
||||||
|
|||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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
@@ -1,52 +0,0 @@
|
|||||||
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
@@ -1,18 +0,0 @@
|
|||||||
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
@@ -1,61 +0,0 @@
|
|||||||
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
@@ -1,104 +0,0 @@
|
|||||||
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
@@ -1,57 +0,0 @@
|
|||||||
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
@@ -1,52 +0,0 @@
|
|||||||
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
@@ -1,20 +0,0 @@
|
|||||||
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
@@ -1,57 +0,0 @@
|
|||||||
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
@@ -1,22 +0,0 @@
|
|||||||
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
@@ -1,90 +0,0 @@
|
|||||||
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
@@ -1,81 +0,0 @@
|
|||||||
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
@@ -1,84 +0,0 @@
|
|||||||
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,50 +172,6 @@ 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,7 +2,6 @@ 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;
|
||||||
@@ -65,20 +64,6 @@ 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<>();
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
|
|||||||
public RestTemplate restTemplate() {
|
public RestTemplate restTemplate() {
|
||||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
|
||||||
// 연결 타임아웃: 10초
|
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
|
||||||
factory.setConnectTimeout(10000);
|
factory.setConnectTimeout(5000);
|
||||||
|
|
||||||
// 읽기 타임아웃: 30초
|
// 읽기 타임아웃: 5초
|
||||||
factory.setReadTimeout(30000);
|
factory.setReadTimeout(5000);
|
||||||
|
|
||||||
return new RestTemplate(factory);
|
return new RestTemplate(factory);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ 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,11 +37,3 @@ 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,10 +22,6 @@ 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,19 +153,6 @@ 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
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
/**
|
|
||||||
* 비밀번호 전송암호화 (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,9 +16,7 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">본인 확인</h2>
|
<h2 class="page-outer-title">본인 확인</h2>
|
||||||
|
|
||||||
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다. -->
|
<form th:action="@{/auth/stepup/password}" method="post">
|
||||||
<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,9 +13,8 @@
|
|||||||
다시 서비스를 이용하시려면 <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" data-encrypt-fields="password">
|
method="post">
|
||||||
<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,12 +233,7 @@
|
|||||||
customPopups.showAlert('입력 정보 확인');
|
customPopups.showAlert('입력 정보 확인');
|
||||||
} else {
|
} else {
|
||||||
refreshCsrfAndThen(form, function () {
|
refreshCsrfAndThen(form, function () {
|
||||||
// 비밀번호 전송암호화. 기능이 꺼져 있거나 브라우저가 Web Crypto 를 못 쓰면
|
form.submit();
|
||||||
// 아무 일도 하지 않고 평문 그대로 제출된다(실패해도 reject 하지 않는다).
|
|
||||||
// form.submit() 은 submit 이벤트를 발생시키지 않아 선언적 훅이 걸리지 않으므로
|
|
||||||
// 여기서 직접 호출한다.
|
|
||||||
window.portalPasswordCrypto.encryptForm(form, ['password'])
|
|
||||||
.then(function () { form.submit(); });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
form.classList.add('was-validated');
|
form.classList.add('was-validated');
|
||||||
|
|||||||
@@ -20,10 +20,7 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||||
|
|
||||||
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다(2FA 미요구 경로).
|
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
|
||||||
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">
|
||||||
@@ -130,19 +127,16 @@
|
|||||||
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: enc.password }
|
data: { password: pw }
|
||||||
}).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);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -166,17 +160,11 @@
|
|||||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전송암호화(봉투 치환) 후 제출. 기능이 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
|
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
|
||||||
// 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',
|
||||||
onSuccess: encryptThenSubmit,
|
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
||||||
|
onSuccess: function () { form.submit(); },
|
||||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,10 +16,7 @@
|
|||||||
<div class="password-change-wrapper">
|
<div class="password-change-wrapper">
|
||||||
<h2 class="page-outer-title">비밀번호 변경</h2>
|
<h2 class="page-outer-title">비밀번호 변경</h2>
|
||||||
|
|
||||||
<!-- data-encrypt-fields: password-crypto.js 가 제출 직전 해당 필드를 봉투로 치환한다.
|
<form th:action="@{/password/verify}" method="post">
|
||||||
기능이 꺼져 있거나 브라우저가 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,13 +390,11 @@
|
|||||||
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: enc.password,
|
password: password,
|
||||||
mobileNumber: $('#mobileNumber').val(),
|
mobileNumber: $('#mobileNumber').val(),
|
||||||
loginId: $('#loginId').val(),
|
loginId: $('#loginId').val(),
|
||||||
_csrf: $('input[name="_csrf"]').val()
|
_csrf: $('input[name="_csrf"]').val()
|
||||||
@@ -413,7 +411,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// 비밀번호 확인 검증
|
// 비밀번호 확인 검증
|
||||||
$('#confirmPassword').on('blur', function () {
|
$('#confirmPassword').on('blur', function () {
|
||||||
@@ -422,17 +419,12 @@
|
|||||||
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: enc.password,
|
password: $('#password').val(),
|
||||||
confirmPassword: enc.confirmPassword,
|
confirmPassword: confirmPassword,
|
||||||
_csrf: $('input[name="_csrf"]').val()
|
_csrf: $('input[name="_csrf"]').val()
|
||||||
},
|
},
|
||||||
success: function (response) {
|
success: function (response) {
|
||||||
@@ -448,7 +440,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
|||||||
@@ -466,10 +466,7 @@
|
|||||||
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);
|
||||||
// 전송암호화(봉투 치환) 후 제출. 시나리오별로 password 가 없을 수 있으나
|
form.submit();
|
||||||
// 존재하는 필드만 치환하므로 그대로 호출한다.
|
|
||||||
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
|
||||||
.then(function () { form.submit(); });
|
|
||||||
} else {
|
} else {
|
||||||
console.log('Form validation failed');
|
console.log('Form validation failed');
|
||||||
|
|
||||||
|
|||||||
@@ -185,9 +185,7 @@
|
|||||||
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
|
authCompletedField.value = isAuthVerified ? 'Y' : 'N';
|
||||||
form.appendChild(authCompletedField);
|
form.appendChild(authCompletedField);
|
||||||
|
|
||||||
// 전송암호화(봉투 치환) 후 제출. 꺼져 있으면 즉시 resolve 되어 평문 그대로 간다.
|
form.submit();
|
||||||
window.portalPasswordCrypto.encryptForm(form, ['password', 'confirmPassword'])
|
|
||||||
.then(function () { form.submit(); });
|
|
||||||
} else {
|
} else {
|
||||||
customPopups.showAlert(!isAuthVerified
|
customPopups.showAlert(!isAuthVerified
|
||||||
? '인증번호확인을 완료해주세요.'
|
? '인증번호확인을 완료해주세요.'
|
||||||
|
|||||||
@@ -166,13 +166,10 @@
|
|||||||
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(enc.password)
|
body: 'password=' + encodeURIComponent(password)
|
||||||
});
|
|
||||||
}).then(function (r) { return r.json(); });
|
}).then(function (r) { return r.json(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,16 +32,6 @@
|
|||||||
};
|
};
|
||||||
</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'}"/>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!-- fragment/popup/customPopups.html -->
|
<!-- fragment/popup/customPopups.html -->
|
||||||
<div th:fragment="customPopups">
|
<div th:fragment="customPopups">
|
||||||
<div th:replace="fragment/popup/customPopup :: #customAlert"></div>
|
<div th:replace="~{fragment/popup/customPopup :: #customAlert}"></div>
|
||||||
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
|
<div th:replace="~{fragment/popup/emailValidationPopup :: #emailValidationPopup}"></div>
|
||||||
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
|
<div th:replace="~{fragment/popup/customPopup2 :: #customConfirm}"></div>
|
||||||
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
|
<div th:replace="~{fragment/popup/passwordInputPopup :: passwordInputPopup}"></div>
|
||||||
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
|
<div th:replace="~{fragment/popup/terminateRequestPopup :: terminateRequestPopup}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/head :: headFragment">
|
<head th:replace="~{fragment/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||||
<section layout:fragment="pagePopups"></section>
|
<section layout:fragment="pagePopups"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<th:block layout:fragment="script">
|
<th:block layout:fragment="script">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
<head th:replace="fragment/djbank/head :: headFragment">
|
<head th:replace="~{fragment/djbank/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="loading-overlay" style="display: none;">
|
<div class="loading-overlay" style="display: none;">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="navigation" th:replace="fragment/djbank/header_nav :: headerFragment"></div>
|
<div class="navigation" th:replace="~{fragment/djbank/header_nav :: headerFragment}"></div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
</th:block>
|
</th:block>
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
|
||||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
|
||||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
|
||||||
<section layout:fragment="pagePopups"></section>
|
<section layout:fragment="pagePopups"></section>
|
||||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/head :: headFragment">
|
<head th:replace="~{fragment/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,31 +3,31 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
|
||||||
|
|
||||||
<head th:replace="fragment/head :: headFragment">
|
<head th:replace="~{fragment/head :: headFragment}">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- header -->
|
<!-- header -->
|
||||||
<header th:replace="fragment/admin_header :: headerFragment"></header>
|
<header th:replace="~{fragment/admin_header :: headerFragment}"></header>
|
||||||
<th:block th:replace="fragment/admin_header :: headerScript"></th:block>
|
<th:block th:replace="~{fragment/admin_header :: headerScript}"></th:block>
|
||||||
<nav th:replace="fragment/header :: navFragment"></nav>
|
<nav th:replace="~{fragment/header :: navFragment}"></nav>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- csrf -->
|
<!-- csrf -->
|
||||||
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||||
<div>
|
<div>
|
||||||
<aside th:replace="fragment/menu :: menuFragment"></aside>
|
<aside th:replace="~{fragment/menu :: menuFragment}"></aside>
|
||||||
<th:block th:replace="fragment/menu :: menuScript"></th:block>
|
<th:block th:replace="~{fragment/menu :: menuScript}"></th:block>
|
||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<div layout:fragment="contentFragment">
|
<div layout:fragment="contentFragment">
|
||||||
</div>
|
</div>
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
</th:block>
|
</th:block>
|
||||||
<footer th:replace="fragment/footer :: footerFragment"></footer>
|
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
<h2>2. 프래그먼트 대체</h2>
|
<h2>2. 프래그먼트 대체</h2>
|
||||||
<header th:replace="fragment/header :: headerFragment"></header>
|
<header th:replace="~{fragment/header :: headerFragment}"></header>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
<h2>14. 프래그먼트</h2>
|
<h2>14. 프래그먼트</h2>
|
||||||
<div th:include="fragment/footer :: footerFragment">여기에 푸터가 들어갑니다</div>
|
<div th:include="~{fragment/footer :: footerFragment}">여기에 푸터가 들어갑니다</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="example">
|
<div class="example">
|
||||||
|
|||||||
Reference in New Issue
Block a user