디자인 수정

This commit is contained in:
2026-04-08 02:27:27 +09:00
parent db8df83b31
commit 4603ac7fd6
20 changed files with 4314 additions and 307 deletions
@@ -0,0 +1,71 @@
package com.erp.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 비밀번호 해시 검증/생성 유틸.
*
* <p>운영 환경에 MD5 로 저장된 레거시 해시와 BCrypt 신규 해시가 공존하는 점진 마이그레이션 단계용.
* BCrypt 해시는 항상 {@code $2a$/$2b$/$2y$} prefix 로 시작하므로 prefix 로 알고리즘을 식별한다.
*
* <ul>
* <li>{@link #matches(String, String)} - 입력 평문과 저장 해시를 비교 (알고리즘 자동 판별)</li>
* <li>{@link #encode(String)} - BCrypt 로 신규 해시 생성 (회원가입 / 점진 재해시 용)</li>
* <li>{@link #isLegacy(String)} - 저장된 해시가 BCrypt 가 아닌지 (= 재해시 대상인지) 판단</li>
* </ul>
*/
@Component
public class PasswordHasher {
private final PasswordEncoder bcryptEncoder;
@Autowired
public PasswordHasher(PasswordEncoder bcryptEncoder) {
this.bcryptEncoder = bcryptEncoder;
}
/**
* 평문 비밀번호가 저장된 해시와 일치하는지.
* 저장 해시가 BCrypt 면 BCrypt 로, 아니면 MD5 로 비교.
*/
public boolean matches(String rawPassword, String storedHash) {
if (rawPassword == null || storedHash == null) {
return false;
}
if (isBcrypt(storedHash)) {
return bcryptEncoder.matches(rawPassword, storedHash);
}
return md5(rawPassword).equals(storedHash);
}
/** BCrypt 로 새 해시 생성. 회원가입과 점진 재해시 양쪽에서 사용. */
public String encode(String rawPassword) {
return bcryptEncoder.encode(rawPassword);
}
/** 저장된 해시가 BCrypt 가 아니면 (즉 레거시 MD5 면) true. 점진 재해시 대상 판별. */
public boolean isLegacy(String storedHash) {
return storedHash != null && !isBcrypt(storedHash);
}
private boolean isBcrypt(String hash) {
return hash.startsWith("$2a$") || hash.startsWith("$2b$") || hash.startsWith("$2y$");
}
private String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return String.format("%032x", new BigInteger(1, digest));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 해시 생성 실패", e);
}
}
}
@@ -1,6 +1,7 @@
package com.erp.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -15,6 +16,7 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@@ -24,6 +26,13 @@ public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
/**
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invion.com").
* application.yml 또는 환경변수 CORS_ALLOWED_ORIGINS 로 설정.
*/
@Value("${cors.allowed-origins}")
private String corsAllowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
@@ -31,6 +40,10 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// ⚠️ 95개 컨트롤러 중 자체 토큰 검증을 하는 건 AuthController 한 곳뿐.
// SecurityConfig 단계에서 강제 인증을 켜면 회귀 위험 너무 큼 (Phase 3 별도 트랙).
// 그동안은 기존 동작 유지하되, JwtAuthenticationFilter 가 valid 토큰일 때
// SecurityContext 를 채워주어 컨트롤러가 사용자 정보 attribute 를 받을 수 있게 함.
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated()
)
@@ -48,7 +61,12 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
// 와일드카드 금지 — 환경변수에서 받은 화이트리스트만 허용
List<String> origins = Arrays.stream(corsAllowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
config.setAllowedOrigins(origins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
@@ -2,17 +2,13 @@ package com.erp.service;
import com.erp.common.BaseService;
import com.erp.security.JwtTokenProvider;
import com.erp.security.PasswordHasher;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
@@ -26,70 +22,94 @@ public class AuthService extends BaseService {
private static final String NS_ADMIN = "admin.";
@Value("${auth.master-password:qlalfqjsgh11}")
private String masterPassword;
/** 로그인 실패 시 사용자에게 노출되는 통일 메시지 (user enumeration 방지) */
private static final String LOGIN_FAIL_MESSAGE = "아이디 또는 비밀번호를 확인해주세요.";
/** 락아웃 카운팅 윈도우 (분) */
private static final int LOCKOUT_WINDOW_MINUTES = 5;
/** 락아웃 한계 (실패 횟수) */
private static final int LOCKOUT_MAX_FAILURES = 5;
/** 락아웃 발생 시 사용자에게 노출되는 메시지 */
private static final String LOCKOUT_MESSAGE =
"로그인 실패가 너무 많습니다. " + LOCKOUT_WINDOW_MINUTES + "분 후 다시 시도해주세요.";
private final JwtTokenProvider jwtTokenProvider;
private final PasswordHasher passwordHasher;
/**
* 로그인 처리
* Node.js AuthService.processLogin() 포팅
*
* 보안 변경 사항 (2026-04-08):
* - 마스터 패스워드 백도어 제거
* - BCrypt 점진 재해시 (MD5 검증 성공 시 BCrypt 로 자동 재저장)
* - 로그인 락아웃 (최근 N분 내 N회 이상 실패 시 차단)
* - User enumeration 방지 (모든 실패 응답 메시지 통일)
*/
public Map<String, Object> login(Map<String, Object> params) {
String userId = (String) params.get("user_id");
String password = (String) params.get("password");
String remoteAddr = (String) params.getOrDefault("remote_addr", "unknown");
// 0. 락아웃 체크 — 비번 검증 전에 먼저 차단
if (isLockedOut(userId)) {
log.warn("로그인 락아웃 차단: {} ({})", userId, remoteAddr);
recordLoginAttempt(userId, false, "락아웃 차단", remoteAddr);
Map<String, Object> result = new HashMap<>();
result.put("login_failed", true);
result.put("error_reason", LOCKOUT_MESSAGE);
return result;
}
// 1. 비밀번호 검증
boolean loginSuccess = false;
String errorReason = null;
String internalErrorReason = null;
String storedPw = null;
Map<String, Object> pwRow = sqlSession.selectOne("auth.selectUserPassword", Map.of("user_id", userId));
if (pwRow == null) {
errorReason = "사용자가 존재하지 않습니다.";
internalErrorReason = "사용자가 존재하지 않습니다.";
} else {
String storedPw = (String) pwRow.get("user_password");
if (masterPassword.equals(password)) {
storedPw = (String) pwRow.get("user_password");
if (passwordHasher.matches(password, storedPw)) {
loginSuccess = true;
log.debug("마스터 패스워드로 로그인 성공: {}", userId);
} else if (storedPw != null && md5(password).equals(storedPw)) {
loginSuccess = true;
log.debug("MD5 패스워드 일치로 로그인 성공: {}", userId);
} else {
errorReason = "패스워드가 일치하지 않습니다.";
internalErrorReason = "패스워드가 일치하지 않습니다.";
}
}
// 2. 로그인 로그 기록 (실패해도 로그인 프로세스 유지)
try {
Map<String, Object> logParams = new HashMap<>();
logParams.put("system_name", "PMS");
logParams.put("user_id", userId);
logParams.put("login_result", loginSuccess);
logParams.put("error_message", errorReason);
logParams.put("remote_addr", remoteAddr);
logParams.put("recptn_dt", null);
logParams.put("recptn_rslt_dtl", null);
logParams.put("recptn_rslt", null);
logParams.put("recptn_rslt_cd", null);
sqlSession.insert("auth.insertLoginLog", logParams);
} catch (Exception e) {
log.warn("로그인 로그 기록 실패 (무시): {}", e.getMessage());
}
recordLoginAttempt(userId, loginSuccess, internalErrorReason, remoteAddr);
if (!loginSuccess) {
Map<String, Object> result = new HashMap<>();
result.put("login_failed", true);
result.put("error_reason", errorReason);
// 사용자에게는 항상 통일 메시지 (user enumeration 방지)
result.put("error_reason", LOGIN_FAIL_MESSAGE);
return result;
}
// 2-1. BCrypt 점진 재해시 — MD5 로 검증 성공한 경우 BCrypt 로 갱신
if (passwordHasher.isLegacy(storedPw)) {
try {
String newHash = passwordHasher.encode(password);
Map<String, Object> updateParams = new HashMap<>();
updateParams.put("user_id", userId);
updateParams.put("user_password", newHash);
sqlSession.update("auth.updateUserPassword", updateParams);
log.info("비밀번호 BCrypt 재해시 완료: {}", userId);
} catch (Exception e) {
log.warn("비밀번호 BCrypt 재해시 실패 (로그인은 정상 진행): {}", e.getMessage());
}
}
// 3. 사용자 정보 조회
Map<String, Object> userInfoRow = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
if (userInfoRow == null) {
Map<String, Object> result = new HashMap<>();
result.put("login_failed", true);
result.put("error_reason", "사용자 정보를 조회할 수 없습니다.");
result.put("error_reason", LOGIN_FAIL_MESSAGE);
return result;
}
@@ -244,7 +264,6 @@ public class AuthService extends BaseService {
result.put("user_name", getStr(dbUser, "user_name", ""));
result.put("dept_name", getStr(dbUser, "dept_name", ""));
result.put("company_code", companyCode);
result.put("company_code", companyCode);
result.put("user_type", userType);
result.put("user_type_name", getStr(dbUser, "user_type_name", "일반사용자"));
result.put("email", getStr(dbUser, "email", ""));
@@ -355,8 +374,8 @@ public class AuthService extends BaseService {
throw new IllegalArgumentException("이미 등록된 차량번호입니다.");
}
// 비밀번호 MD5 해싱
String hashedPassword = md5((String) params.get("password"));
// 신규 회원가입은 BCrypt 로 저장 (점진 마이그레이션 정책에 따라 신규는 BCrypt 강제)
String hashedPassword = passwordHasher.encode((String) params.get("password"));
Map<String, Object> insertParams = new HashMap<>(params);
insertParams.put("user_password", hashedPassword);
@@ -369,18 +388,48 @@ public class AuthService extends BaseService {
// ── 내부 유틸 ──────────────────────────────────────────────────────────
/**
* 락아웃 판단 — 최근 LOCKOUT_WINDOW_MINUTES 분 내 동일 user_id 의 실패 건수가
* LOCKOUT_MAX_FAILURES 이상이면 차단.
*/
private boolean isLockedOut(String userId) {
if (userId == null || userId.isBlank()) {
return false;
}
try {
Map<String, Object> p = new HashMap<>();
p.put("user_id", userId);
p.put("lockout_window_minutes", LOCKOUT_WINDOW_MINUTES);
Integer fails = sqlSession.selectOne("auth.countRecentLoginFailures", p);
return fails != null && fails >= LOCKOUT_MAX_FAILURES;
} catch (Exception e) {
// 카운트 쿼리 실패는 인증 자체를 막지 않는다 (가용성 우선)
log.warn("락아웃 카운트 조회 실패 (무시): {}", e.getMessage());
return false;
}
}
/** 로그인 시도 결과 1건 기록 */
private void recordLoginAttempt(String userId, boolean success, String errorMessage, String remoteAddr) {
try {
Map<String, Object> logParams = new HashMap<>();
logParams.put("system_name", "PMS");
logParams.put("user_id", userId);
logParams.put("login_result", success);
logParams.put("error_message", errorMessage);
logParams.put("remote_addr", remoteAddr);
logParams.put("recptn_dt", null);
logParams.put("recptn_rslt_dtl", null);
logParams.put("recptn_rslt", null);
logParams.put("recptn_rslt_cd", null);
sqlSession.insert("auth.insertLoginLog", logParams);
} catch (Exception e) {
log.warn("로그인 로그 기록 실패 (무시): {}", e.getMessage());
}
}
private String getStr(Map<String, Object> map, String key, String defaultVal) {
Object val = map.get(key);
return val != null ? val.toString() : defaultVal;
}
private String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return String.format("%032x", new BigInteger(1, digest));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 해시 생성 실패", e);
}
}
}