Files
invyone/backend-spring/src/main/java/com/erp/service/AuthService.java
T
gbpark 68f85f3736 회사 관리 기능 확장 + 테넌트/비번 보안 하드닝
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼,
  ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지
- 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code
  ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code)
  동시 반환
- 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼):
  CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 +
  CompanyMgmtController + SuperAdminGuard
- 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종
  (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport
- 프로비저닝 마법사: force_password_change 토글 반영
- 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode
  (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED)
  전역 리다이렉트
- 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:36:05 +09:00

534 lines
25 KiB
Java

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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService extends BaseService {
private static final String NS_ADMIN = "admin.";
/** 로그인 실패 시 사용자에게 노출되는 통일 메시지 (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;
/**
* 로그인 처리
*
* 보안 변경 사항 (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 internalErrorReason = null;
String storedPw = null;
Map<String, Object> pwRow = sqlSession.selectOne("auth.selectUserPassword", Map.of("user_id", userId));
if (pwRow == null) {
internalErrorReason = "사용자가 존재하지 않습니다.";
} else {
storedPw = (String) pwRow.get("user_password");
if (passwordHasher.matches(password, storedPw)) {
loginSuccess = true;
} else {
internalErrorReason = "패스워드가 일치하지 않습니다.";
}
}
// 2. 로그인 로그 기록 (실패해도 로그인 프로세스 유지)
recordLoginAttempt(userId, loginSuccess, internalErrorReason, remoteAddr);
if (!loginSuccess) {
Map<String, Object> result = new HashMap<>();
result.put("login_failed", true);
// 사용자에게는 항상 통일 메시지 (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", LOGIN_FAIL_MESSAGE);
return result;
}
// DB resultType="map" → 키가 DB 컬럼명 소문자(PostgreSQL)로 옴
String companyCode = getStr(userInfoRow, "company_code", null);
String userType = getStr(userInfoRow, "user_type", "USER");
// 4. 회사명 조회
String companyName = "";
if (companyCode != null) {
Map<String, Object> companyRow = sqlSession.selectOne("auth.selectCompanyName",
Map.of("company_code", companyCode));
companyName = companyRow != null ? getStr(companyRow, "company_name", "") : "";
}
// 첫 로그인 비밀번호 강제 변경 플래그 (RUN_082)
Object rawFpc = userInfoRow.get("force_password_change");
boolean forcePasswordChange = rawFpc instanceof Boolean
? (Boolean) rawFpc
: rawFpc != null && Boolean.parseBoolean(rawFpc.toString());
// 5. JWT 토큰 생성 — Node.js 동일 페이로드 구조
Map<String, Object> personBean = new HashMap<>();
personBean.put("user_id", userId);
personBean.put("user_name", getStr(userInfoRow, "user_name", ""));
personBean.put("dept_name", getStr(userInfoRow, "dept_name", ""));
personBean.put("company_code", companyCode);
personBean.put("company_name", companyName);
personBean.put("user_type", userType);
personBean.put("user_type_name", getStr(userInfoRow, "user_type_name", "일반사용자"));
personBean.put("force_password_change", forcePasswordChange);
String token = jwtTokenProvider.generateToken(personBean);
// 6. firstMenuPath 계산 (Node.js authController 동일 로직)
String firstMenuPath = null;
try {
Map<String, Object> menuParams = new HashMap<>();
menuParams.put("company_code", companyCode);
menuParams.put("user_type", userType);
menuParams.put("user_lang", "ko");
List<Map<String, Object>> menuList = sqlSession.selectList(NS_ADMIN + "select_user_menu_list", menuParams);
firstMenuPath = menuList.stream()
.filter(m -> {
Object levObj = m.get("lev");
int lev = levObj != null ? ((Number) levObj).intValue() : 0;
String url = getStr(m, "menu_url", "");
return lev >= 2 && !url.trim().isEmpty() && !"#".equals(url);
})
.map(m -> getStr(m, "menu_url", ""))
.findFirst().orElse(null);
} catch (Exception e) {
log.warn("firstMenuPath 조회 실패 (무시): {}", e.getMessage());
}
// 7. popLandingPath 계산 (Node.js authController 동일 로직)
String popLandingPath = null;
try {
Map<String, Object> popParams = new HashMap<>();
popParams.put("company_code", companyCode);
popParams.put("user_type", userType);
Map<String, Object> parentMenu = sqlSession.selectOne(NS_ADMIN + "select_pop_parent_menu", popParams);
if (parentMenu != null) {
Map<String, Object> childParams = new HashMap<>();
childParams.put("parent_objid", parentMenu.get("objid"));
childParams.put("parent_company_code", parentMenu.get("company_code"));
List<Map<String, Object>> childMenus = sqlSession.selectList(NS_ADMIN + "select_pop_child_menus", childParams);
Map<String, Object> landingMenu = childMenus.stream()
.filter(m -> {
Object desc = m.get("menu_desc");
return desc != null && desc.toString().contains("[POP_LANDING]");
})
.findFirst().orElse(null);
if (landingMenu != null) {
popLandingPath = getStr(landingMenu, "menu_url", null);
} else if (childMenus.size() == 1) {
popLandingPath = getStr(childMenus.get(0), "menu_url", null);
} else if (childMenus.size() > 1) {
popLandingPath = "/pop";
}
}
} catch (Exception e) {
log.warn("popLandingPath 조회 실패 (무시): {}", e.getMessage());
}
// 8. 응답 data 구성 (Node.js 응답 형식 일치)
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("user_id", userId);
userInfo.put("user_name", getStr(userInfoRow, "user_name", ""));
userInfo.put("dept_name", getStr(userInfoRow, "dept_name", ""));
userInfo.put("company_code", companyCode);
Map<String, Object> data = new HashMap<>();
data.put("user_info", userInfo);
data.put("token", token);
data.put("first_menu_path", firstMenuPath);
data.put("pop_landing_path", popLandingPath);
data.put("force_password_change", forcePasswordChange);
log.info("로그인 성공: {} ({}) force_pw_change={}", userId, remoteAddr, forcePasswordChange);
return data;
}
/**
* 본인 비밀번호 변경 (로그인된 사용자 기준).
* - 현재 비밀번호 BCrypt/legacy 검증
* - 새 비밀번호 BCrypt 해시 저장 + force_password_change=false 로 클리어
* - 플래그가 꺼진 새 JWT 를 발급하여 반환 — 클라이언트는 즉시 교체해야
* `ForcePasswordChangeGuardFilter` 가 다른 API 호출을 차단하지 않는다.
*
* @return `{ "token": newJwt }` — 호출자가 응답에 실어 프론트로 전달
* @throws IllegalArgumentException 현재 비밀번호 불일치 / 정책 위반
*/
public Map<String, Object> changePassword(String oldToken, String currentPassword, String newPassword) {
if (oldToken == null || !jwtTokenProvider.validateToken(oldToken)) {
throw new IllegalArgumentException("인증 정보가 없습니다.");
}
if (currentPassword == null || newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("새 비밀번호는 8자 이상이어야 합니다.");
}
if (currentPassword.equals(newPassword)) {
throw new IllegalArgumentException("기존 비밀번호와 동일합니다. 다른 비밀번호를 사용하세요.");
}
Claims claims = jwtTokenProvider.getClaims(oldToken);
String userId = claims.get("user_id", String.class);
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("인증 정보가 없습니다.");
}
Map<String, Object> pwRow = sqlSession.selectOne("auth.selectUserPassword", Map.of("user_id", userId));
if (pwRow == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
}
String storedPw = (String) pwRow.get("user_password");
if (!passwordHasher.matches(currentPassword, storedPw)) {
throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다.");
}
String newHash = passwordHasher.encode(newPassword);
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("user_password", newHash);
sqlSession.update("auth.updateUserPasswordAndClearForce", params);
log.info("비밀번호 변경 + 강제 플래그 해제: {}", userId);
// 기존 claim 을 그대로 유지하되 force_password_change=false 로 새 JWT 발급
Map<String, Object> personBean = new HashMap<>();
personBean.put("user_id", userId);
personBean.put("user_name", claims.get("user_name", String.class));
personBean.put("dept_name", claims.get("dept_name", String.class));
personBean.put("company_code", claims.get("company_code", String.class));
personBean.put("company_name", claims.get("company_name", String.class));
personBean.put("user_type", claims.get("user_type", String.class));
personBean.put("user_type_name", claims.get("user_type_name", String.class));
personBean.put("force_password_change", false);
String newToken = jwtTokenProvider.generateToken(personBean);
Map<String, Object> result = new HashMap<>();
result.put("token", newToken);
return result;
}
/**
* 토큰 갱신
* 기존 클레임을 그대로 유지하고 만료시간만 갱신
*/
public Map<String, Object> refreshToken(String token) {
Claims claims = jwtTokenProvider.getClaims(token);
String userId = claims.get("user_id", String.class);
Map<String, Object> personBean = new HashMap<>();
personBean.put("user_id", userId);
personBean.put("user_name", claims.get("user_name", String.class));
personBean.put("dept_name", claims.get("dept_name", String.class));
personBean.put("company_code", claims.get("company_code", String.class));
personBean.put("company_name", claims.get("company_name", String.class));
personBean.put("user_type", claims.get("user_type", String.class));
personBean.put("user_type_name", claims.get("user_type_name", String.class));
// force_password_change 는 **DB 를 source of truth 로** 재조회.
// - 관리자가 다른 세션에서 비번 리셋 → DB fpc=TRUE → 해당 유저가 리프레시할 때 반영.
// - 본인이 변경 직후 발급된 토큰은 changePassword() 가 별도 경로로 false 로 만듦.
// DB 조회 실패 시엔 claim 을 그대로 쓰되, TRUE 였으면 TRUE 유지 (절대 강제를 해제하지 않도록).
boolean forcePasswordChange;
try {
Map<String, Object> row = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
Object rawFpc = row == null ? null : row.get("force_password_change");
forcePasswordChange = rawFpc instanceof Boolean
? (Boolean) rawFpc
: rawFpc != null && Boolean.parseBoolean(rawFpc.toString());
} catch (Exception e) {
log.warn("[refreshToken] fpc DB 재조회 실패, claim 값 유지: {}", e.getMessage());
Boolean claimFpc = claims.get("force_password_change", Boolean.class);
forcePasswordChange = Boolean.TRUE.equals(claimFpc);
}
personBean.put("force_password_change", forcePasswordChange);
String newToken = jwtTokenProvider.generateToken(personBean);
Map<String, Object> data = new HashMap<>();
data.put("token", newToken);
return data;
}
/**
* 현재 사용자 정보 조회 (/api/auth/me)
* JWT의 companyCode를 우선 사용 (회사 전환 지원)
*/
public Map<String, Object> getUserInfo(String token) {
Claims claims = jwtTokenProvider.getClaims(token);
String userId = claims.get("user_id", String.class);
String jwtCompanyCode = claims.get("company_code", String.class);
String jwtUserType = claims.get("user_type", String.class);
Map<String, Object> dbUser = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
if (dbUser == null) {
return null;
}
// 권한명 목록 조회
List<Map<String, Object>> authList = sqlSession.selectList("auth.selectUserAuth", Map.of("user_id", userId));
String authNames = authList.stream()
.map(a -> getStr(a, "auth_name", ""))
.collect(Collectors.joining(","));
String companyCode = jwtCompanyCode != null ? jwtCompanyCode
: getStr(dbUser, "company_code", null);
String userType = jwtUserType != null ? jwtUserType
: getStr(dbUser, "user_type", "USER");
// photo 변환 (byte[] → base64)
Object rawPhoto = dbUser.get("photo");
String photoStr = null;
if (rawPhoto instanceof byte[] bytes && bytes.length > 0) {
photoStr = "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes);
}
// force_password_change 는 JWT claim 에서 읽는다. ForcePasswordChangeGuardFilter 가
// 같은 claim 으로 판단하므로, DB 를 소스로 쓰면 비번 변경 직후 구토큰을 든 다른 탭에서
// /auth/me → DB fpc=false → AuthGuard 통과 → 업무 API 403 PASSWORD_CHANGE_REQUIRED 로
// 갇힘. claim 을 같이 써야 필터와 일관된다.
Boolean fpcClaim = claims.get("force_password_change", Boolean.class);
boolean forcePasswordChange = Boolean.TRUE.equals(fpcClaim);
Map<String, Object> result = new HashMap<>();
result.put("user_id", userId);
result.put("user_name", getStr(dbUser, "user_name", ""));
result.put("dept_name", getStr(dbUser, "dept_name", ""));
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", ""));
result.put("photo", photoStr);
result.put("locale", getStr(dbUser, "locale", "KR"));
result.put("dept_code", dbUser.get("dept_code"));
result.put("auth_name", authNames);
result.put("is_admin", "ADMIN".equals(userType) || "SUPER_ADMIN".equals(userType)
|| "COMPANY_ADMIN".equals(userType));
result.put("force_password_change", forcePasswordChange);
return result;
}
/**
* 인증 상태 확인 (/api/auth/status)
*/
public Map<String, Object> checkAuthStatus(String token) {
Map<String, Object> result = new HashMap<>();
if (token == null || !jwtTokenProvider.validateToken(token)) {
result.put("is_logged_in", false);
result.put("is_admin", false);
return result;
}
Claims claims = jwtTokenProvider.getClaims(token);
String userId = claims.get("user_id", String.class);
String userType = claims.get("user_type", String.class);
boolean isAdmin = "plm_admin".equals(userId) || "ADMIN".equals(userType)
|| "SUPER_ADMIN".equals(userType);
Boolean fpc = claims.get("force_password_change", Boolean.class);
result.put("is_logged_in", true);
result.put("is_admin", isAdmin);
result.put("force_password_change", Boolean.TRUE.equals(fpc));
return result;
}
/**
* 로그아웃 (로그 기록)
*/
public void logout(String userId, String remoteAddr) {
try {
Map<String, Object> logParams = new HashMap<>();
logParams.put("system_name", "PMS");
logParams.put("user_id", userId);
logParams.put("login_result", false);
logParams.put("error_message", "로그아웃");
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);
log.info("로그아웃 완료: {} ({})", userId, remoteAddr);
} catch (Exception e) {
log.warn("로그아웃 로그 기록 실패 (무시): {}", e.getMessage());
}
}
/**
* 회사 전환 (SUPER_ADMIN 전용)
*/
public Map<String, Object> switchCompany(String token, String targetCompanyCode) {
Claims claims = jwtTokenProvider.getClaims(token);
String userId = claims.get("user_id", String.class);
String userType = claims.get("user_type", String.class);
if (!"SUPER_ADMIN".equals(userType)) {
throw new IllegalArgumentException("회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.");
}
// 회사 코드 존재 여부 확인 ("*" 제외)
if (!"*".equals(targetCompanyCode)) {
Map<String, Object> company = sqlSession.selectOne("auth.selectCompanyByCode",
Map.of("company_code", targetCompanyCode));
if (company == null) {
throw new IllegalArgumentException("존재하지 않는 회사 코드입니다.");
}
}
Map<String, Object> personBean = new HashMap<>();
personBean.put("user_id", userId);
personBean.put("user_name", claims.get("user_name", String.class));
personBean.put("dept_name", claims.get("dept_name", String.class));
personBean.put("company_code", targetCompanyCode);
personBean.put("company_name", claims.get("company_name", String.class));
personBean.put("user_type", userType);
personBean.put("user_type_name", claims.get("user_type_name", String.class));
String newToken = jwtTokenProvider.generateToken(personBean);
log.info("회사 전환 성공: {} → {}", userId, targetCompanyCode);
Map<String, Object> result = new HashMap<>();
result.put("token", newToken);
result.put("company_code", targetCompanyCode);
return result;
}
/**
* 공차중계 회원가입
*/
@Transactional
public void signupDriver(Map<String, Object> params) {
String userId = (String) params.get("user_id");
String vehicleNumber = (String) params.get("vehicle_number");
// 아이디 중복 확인
if (sqlSession.selectOne("auth.selectUserById", Map.of("user_id", userId)) != null) {
throw new IllegalArgumentException("이미 존재하는 아이디입니다.");
}
// 차량번호 중복 확인
if (sqlSession.selectOne("auth.selectVehicleByNumber", Map.of("vehicle_number", vehicleNumber)) != null) {
throw new IllegalArgumentException("이미 등록된 차량번호입니다.");
}
// 신규 회원가입은 BCrypt 로 저장 (점진 마이그레이션 정책에 따라 신규는 BCrypt 강제)
String hashedPassword = passwordHasher.encode((String) params.get("password"));
Map<String, Object> insertParams = new HashMap<>(params);
insertParams.put("user_password", hashedPassword);
insertParams.put("signup_company_code", "COMPANY_13");
sqlSession.insert("auth.insertUserSignup", insertParams);
sqlSession.insert("auth.insertVehicle", insertParams);
log.info("공차중계 회원가입 성공: {}, 차량번호: {}", userId, vehicleNumber);
}
// ── 내부 유틸 ──────────────────────────────────────────────────────────
/**
* 락아웃 판단 — 최근 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;
}
}