디자인 수정
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user