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>
534 lines
25 KiB
Java
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;
|
|
}
|
|
}
|