회사 관리 기능 확장 + 테넌트/비번 보안 하드닝

- 첫 로그인 비번 강제 변경 (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>
This commit is contained in:
2026-04-25 00:36:05 +09:00
parent 06998cd2a5
commit 68f85f3736
58 changed files with 6110 additions and 316 deletions
@@ -138,6 +138,48 @@ public class AuthController {
}
}
/**
* POST /api/auth/change-password
*
* 본인 비밀번호 변경 (첫 로그인 강제 변경 / 평시 변경 공용).
* Bearer 토큰에서 user_id 추출 → current_password 검증 → 새 비번 저장 + force_password_change=false.
*/
@PostMapping("/change-password")
public ResponseEntity<ApiResponse<Map<String, Object>>> changePassword(
@RequestBody Map<String, Object> body,
HttpServletRequest request) {
String token = resolveToken(request);
if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
return ResponseEntity.status(401).body(ApiResponse.error("인증이 필요합니다."));
}
String currentPassword = (String) body.get("current_password");
String newPassword = (String) body.get("new_password");
if (!StringUtils.hasText(currentPassword) || !StringUtils.hasText(newPassword)) {
return ResponseEntity.badRequest().body(ApiResponse.error("현재/새 비밀번호를 모두 입력해주세요."));
}
// Defense in depth — 전역 TenantConsistencyGuardFilter 가 이미 잡지만,
// 비밀번호 변경은 크리티컬하므로 Service 레벨에서도 한 번 더 대조.
// 테넌트 서브도메인 요청인데 JWT.company_code 와 tenant.company_code 가 다르면 즉시 거부.
String tenantCompanyCode = (String) request.getAttribute("tenant_company_code");
String jwtCompanyCode = jwtTokenProvider.getCompanyCode(token);
if (tenantCompanyCode != null && !tenantCompanyCode.equals(jwtCompanyCode)) {
log.warn("[ChangePassword] cross-tenant rejected: jwt={}, tenant={}", jwtCompanyCode, tenantCompanyCode);
return ResponseEntity.status(403).body(ApiResponse.error("로그인한 회사와 접속 도메인이 일치하지 않습니다."));
}
try {
Map<String, Object> data = authService.changePassword(token, currentPassword, newPassword);
return ResponseEntity.ok(ApiResponse.success(data, "비밀번호가 변경되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("비밀번호 변경 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("비밀번호 변경 중 오류가 발생했습니다."));
}
}
/**
* POST /api/auth/signup
*/
@@ -0,0 +1,96 @@
package com.erp.migration;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.List;
/**
* 앱 부팅 시 idempotent 스키마 마이그레이션 실행.
*
* 왜 필요:
* DB-per-tenant 구조 → 메타 DB 1개 + 활성 테넌트 DB N개 에 동일 ALTER 필요.
* runbook 문서 (`db/migrations/RUN_*.md`) 로만 남기면 배포 때 사람이 까먹으면 장애.
* 부팅 때 `IF NOT EXISTS` 로 안전하게 돌려두면 배포 순서/인적 실수와 무관.
*
* 원칙:
* - 각 ALTER 는 반드시 idempotent (`IF NOT EXISTS` / 재실행 안전)
* - 테넌트 DB 하나 실패해도 다른 DB 는 계속 진행 (ERROR 로그만)
* - 메타 DB 가 실패하면 앱 시작은 계속되지만 WARN 크게 남김
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class StartupSchemaMigrator {
private final TenantDbSettings tenantDbSettings;
private final SqlSession sqlSession;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
private static final List<String> MIGRATIONS = List.of(
// RUN_082: 첫 로그인 비밀번호 강제 변경 플래그
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE"
);
@EventListener(ApplicationReadyEvent.class)
public void run() {
log.info("[SchemaMigrator] start — {} migration(s)", MIGRATIONS.size());
String metaDb = parseMetaDbName(metaJdbcUrl);
applyTo(metaDb, "meta");
// 테넌트 목록 조회는 반드시 메타 라우팅으로
DbContextHolder.setMeta();
List<String> tenantDbs;
try {
tenantDbs = sqlSession.selectList("provisioning.listActiveDbNames");
} catch (Exception e) {
log.warn("[SchemaMigrator] tenant list query failed — skipping tenants: {}", e.getMessage());
tenantDbs = List.of();
} finally {
DbContextHolder.clear();
}
int ok = 0, fail = 0;
for (String db : tenantDbs) {
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
if (applyTo(db, "tenant")) ok++; else fail++;
}
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
}
private boolean applyTo(String dbName, String kind) {
String url = tenantDbSettings.buildJdbcUrl(dbName);
try (Connection c = DriverManager.getConnection(url, tenantDbSettings.username(), tenantDbSettings.password());
Statement s = c.createStatement()) {
for (String ddl : MIGRATIONS) {
s.execute(ddl);
}
log.info("[SchemaMigrator] {} db='{}' OK", kind, dbName);
return true;
} catch (Exception e) {
log.error("[SchemaMigrator] {} db='{}' FAILED: {}", kind, dbName, e.getMessage());
return false;
}
}
static String parseMetaDbName(String jdbcUrl) {
int slash = jdbcUrl.lastIndexOf('/');
if (slash < 0) return "invyone";
String tail = jdbcUrl.substring(slash + 1);
int q = tail.indexOf('?');
return q < 0 ? tail : tail.substring(0, q);
}
}
@@ -21,25 +21,29 @@ public class AdminAccountCreator {
private final PasswordEncoder passwordEncoder;
/**
* @param dst 신규 회사 DB 커넥션 (autoCommit 무관, 호출자가 트랜잭션 관리)
* @param companyCode COMPANY_MNG.company_code (FK 성격)
* @param userId 예: qnc_admin
* @param rawPassword 평문 비밀번호 (BCrypt 해시 후 저장됨)
* @param userName 표시 이름
* @param dst 신규 회사 DB 커넥션 (autoCommit 무관, 호출자가 트랜잭션 관리)
* @param companyCode COMPANY_MNG.company_code (FK 성격)
* @param userId 예: qnc_admin
* @param rawPassword 평문 비밀번호 (BCrypt 해시 후 저장됨)
* @param userName 표시 이름
* @param forcePasswordChange true 면 첫 로그인 시 비밀번호 변경을 강제 (RUN_082)
*/
public void createCompanyAdmin(Connection dst, String companyCode, String userId,
String rawPassword, String userName) throws SQLException {
String rawPassword, String userName,
boolean forcePasswordChange) throws SQLException {
String hash = passwordEncoder.encode(rawPassword);
String sql = "INSERT INTO user_info " +
"(user_id, user_password, user_name, company_code, user_type, status, created_date) " +
"VALUES (?, ?, ?, ?, 'COMPANY_ADMIN', 'active', NOW())";
"(user_id, user_password, user_name, company_code, user_type, status, force_password_change, created_date) " +
"VALUES (?, ?, ?, ?, 'COMPANY_ADMIN', 'active', ?, NOW())";
try (PreparedStatement ps = dst.prepareStatement(sql)) {
ps.setString(1, userId);
ps.setString(2, hash);
ps.setString(3, userName);
ps.setString(4, companyCode);
ps.setBoolean(5, forcePasswordChange);
int affected = ps.executeUpdate();
log.info("[Provisioning] CREATE ADMIN: {} (company={}, rows={})", userId, companyCode, affected);
log.info("[Provisioning] CREATE ADMIN: {} (company={}, rows={}, force_pw_change={})",
userId, companyCode, affected, forcePasswordChange);
}
}
}
@@ -0,0 +1,118 @@
package com.erp.provisioning;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 테넌트 DB 의 COMPANY_ADMIN 계정 관련 조회/재설정.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyAdminService {
private final TenantDbSettings settings;
private final PasswordEncoder passwordEncoder;
/**
* 해당 회사 tenant DB 의 COMPANY_ADMIN 계정 1건 조회.
* 비밀번호 해시는 반환하지 않음. 평문 비번도 없음.
*/
public Map<String, Object> getAdmin(String dbName) {
Map<String, Object> out = new LinkedHashMap<>();
try (Connection c = DriverManager.getConnection(
settings.buildJdbcUrl(dbName), settings.username(), settings.password());
PreparedStatement ps = c.prepareStatement(
"SELECT user_id, user_name, status, force_password_change, created_date " +
"FROM user_info WHERE user_type='COMPANY_ADMIN' " +
"ORDER BY created_date ASC LIMIT 1")) {
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
out.put("user_id", rs.getString("user_id"));
out.put("user_name", rs.getString("user_name"));
out.put("status", rs.getString("status"));
out.put("force_password_change", rs.getBoolean("force_password_change"));
out.put("created_date", rs.getTimestamp("created_date"));
out.put("found", true);
} else {
out.put("found", false);
}
}
} catch (SQLException e) {
log.error("[CompanyAdmin] getAdmin failed db={} err={}", dbName, e.getMessage());
out.put("found", false);
out.put("error", e.getMessage());
}
return out;
}
/**
* 관리자 비번 재설정. 새 랜덤 비번 생성 → BCrypt 저장 → force_password_change=true.
* 평문 비번은 1회만 반환 (호출자가 UI 에 1회 노출 후 파기).
*
* @return { admin_user_id, new_password } 또는 { error }
*/
public Map<String, Object> resetAdminPassword(String dbName) {
String newPlain = generateRandomPassword();
String hash = passwordEncoder.encode(newPlain);
Map<String, Object> out = new LinkedHashMap<>();
try (Connection c = DriverManager.getConnection(
settings.buildJdbcUrl(dbName), settings.username(), settings.password())) {
// 대상 user_id 먼저 조회
String userId = null;
try (PreparedStatement sel = c.prepareStatement(
"SELECT user_id FROM user_info WHERE user_type='COMPANY_ADMIN' ORDER BY created_date ASC LIMIT 1");
ResultSet rs = sel.executeQuery()) {
if (rs.next()) userId = rs.getString(1);
}
if (userId == null) {
out.put("error", "no_admin_found");
return out;
}
try (PreparedStatement up = c.prepareStatement(
"UPDATE user_info SET user_password=?, force_password_change=TRUE " +
"WHERE user_id=? AND user_type='COMPANY_ADMIN'")) {
up.setString(1, hash);
up.setString(2, userId);
int affected = up.executeUpdate();
if (affected == 0) {
out.put("error", "no_rows_updated");
return out;
}
log.info("[CompanyAdmin] password reset: db={} user={}", dbName, userId);
}
out.put("admin_user_id", userId);
out.put("new_password", newPlain);
out.put("force_password_change", true);
return out;
} catch (SQLException e) {
log.error("[CompanyAdmin] resetAdminPassword failed db={} err={}", dbName, e.getMessage());
out.put("error", e.getMessage());
return out;
}
}
private static String generateRandomPassword() {
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$";
SecureRandom r = new SecureRandom();
StringBuilder sb = new StringBuilder(14);
for (int i = 0; i < 14; i++) sb.append(chars.charAt(r.nextInt(chars.length())));
return sb.toString();
}
}
@@ -0,0 +1,150 @@
package com.erp.provisioning;
import com.erp.tenant.DbContextHolder;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 회사 관리 화면(/admin/sysMng/subdomainList)의 라이프사이클 액션 감사 로그.
*
* 테이블: COMPANY_AUDIT_LOG (메타 DB). 테넌트 DB 내부 CRUD 로그용 {@link com.erp.service.AuditLogService}
* 와는 스코프가 다름. 이 서비스는 SUPER_ADMIN 전용.
*
* action 상수:
* COMPANY_CREATE — 프로비저닝 성공
* COMPANY_CREATE_FAILED — 프로비저닝 실패 (자동 롤백 완료 후)
* COMPANY_DEACTIVATE — DB_STATUS=suspended
* COMPANY_REACTIVATE — suspended→active 복귀
* COMPANY_DELETE — DROP DATABASE + row 삭제
* ADMIN_PASSWORD_RESET — 관리자 비번 재설정
* TEMPLATES_RECOPY — 템플릿 재복제
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyAuditLogService {
public static final String ACTION_CREATE = "COMPANY_CREATE";
public static final String ACTION_CREATE_FAILED = "COMPANY_CREATE_FAILED";
public static final String ACTION_DEACTIVATE = "COMPANY_DEACTIVATE";
public static final String ACTION_REACTIVATE = "COMPANY_REACTIVATE";
public static final String ACTION_DELETE = "COMPANY_DELETE";
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
private final SqlSession sqlSession;
private final ObjectMapper objectMapper;
/** 성공 기록. */
public void log(String companyCode, String actorUserId, String action, String target, Map<String, Object> details) {
doLog(companyCode, actorUserId, action, target, details, true, null);
}
/** 실패 기록. */
public void logFailure(String companyCode, String actorUserId, String action, String target,
Map<String, Object> details, String errorMessage) {
doLog(companyCode, actorUserId, action, target, details, false, errorMessage);
}
private void doLog(String companyCode, String actorUserId, String action, String target,
Map<String, Object> details, boolean success, String errorMessage) {
// 감사 로그는 항상 메타 DB 에 기록. tenant 컨텍스트 영향 받지 않도록 강제.
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("actor_user_id", actorUserId);
params.put("action", action);
params.put("target", target);
params.put("details_json", details == null ? null : toJson(details));
params.put("success", success);
params.put("error_message", errorMessage);
sqlSession.insert("provisioning.insertAuditLog", params);
} catch (Exception e) {
// 감사 로그 실패가 본 작업을 깨면 안 됨.
log.error("[CompanyAudit] log failed: company={} action={} err={}", companyCode, action, e.getMessage());
} finally {
if (prevCtx == null) {
DbContextHolder.clear();
} else {
DbContextHolder.set(prevCtx);
}
}
}
/** 특정 회사 감사 로그 (최신순, 페이지네이션). */
public Map<String, Object> listByCompany(String companyCode, int page, int limit) {
if (page < 1) page = 1;
if (limit < 1 || limit > 200) limit = 50;
int offset = (page - 1) * limit;
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
p.put("limit", limit);
p.put("offset", offset);
Number totalNum = sqlSession.selectOne("provisioning.countAuditLog", p);
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.selectAuditLog", p);
hydrateDetails(rows);
Map<String, Object> out = new LinkedHashMap<>();
out.put("data", rows);
out.put("total", totalNum == null ? 0 : totalNum.intValue());
out.put("page", page);
out.put("limit", limit);
return out;
}
/** 전체 감사 로그 (헤더 버튼용). */
public Map<String, Object> listAll(int page, int limit, String action) {
if (page < 1) page = 1;
if (limit < 1 || limit > 200) limit = 50;
int offset = (page - 1) * limit;
Map<String, Object> p = new HashMap<>();
p.put("action", action);
p.put("limit", limit);
p.put("offset", offset);
Number totalNum = sqlSession.selectOne("provisioning.countAuditLog", p);
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.selectAuditLog", p);
hydrateDetails(rows);
Map<String, Object> out = new LinkedHashMap<>();
out.put("data", rows);
out.put("total", totalNum == null ? 0 : totalNum.intValue());
out.put("page", page);
out.put("limit", limit);
return out;
}
private String toJson(Map<String, Object> details) {
try {
return objectMapper.writeValueAsString(details);
} catch (Exception e) {
log.warn("[CompanyAudit] JSON serialize failed: {}", e.getMessage());
return null;
}
}
@SuppressWarnings("unchecked")
private void hydrateDetails(List<Map<String, Object>> rows) {
for (Map<String, Object> r : rows) {
Object raw = r.get("details");
if (raw == null) continue;
try {
Map<String, Object> parsed = objectMapper.readValue(raw.toString(), Map.class);
r.put("details", parsed);
} catch (Exception e) {
// 파싱 실패해도 원본 문자열 유지
}
}
}
}
@@ -0,0 +1,149 @@
package com.erp.provisioning;
import com.erp.tenant.CompanyResolver;
import com.erp.tenant.DbContextHolder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 회사 라이프사이클 액션 (비활성화 / 재활성화 / 영구 삭제).
*
* 비활성화: DB_STATUS='suspended' + DEACTIVATED_AT + DEACTIVATION_REASON 기록.
* 로그인 필터가 suspended 를 403 처리 (기존 구현). DB 는 남겨둠.
* 재활성화: DB_STATUS='active' + 위 2 필드 NULL 로 클리어.
* 영구 삭제: DROP DATABASE + COMPANY_MNG row DELETE + CompanyResolver 캐시 무효화.
* 절대 되돌릴 수 없음.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyLifecycleService {
private final SqlSession sqlSession;
private final DatabaseCreator databaseCreator;
private final CompanyResolver companyResolver;
public Map<String, Object> getCompany(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
Map<String, Object> row = sqlSession.selectOne("provisioning.selectCompanyByCode", p);
return row;
} finally {
restoreCtx(prevCtx);
}
}
/**
* 비활성화. 이미 suspended 면 no-op 리턴.
* @return { status, subdomain } 성공 시 | { error } 실패 시
*/
public Map<String, Object> deactivate(String companyCode, String reason) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> company = getCompany(companyCode);
if (company == null) return Map.of("error", "not_found");
String curStatus = (String) company.get("db_status");
if ("suspended".equals(curStatus)) {
return Map.of("error", "already_suspended");
}
if ("provisioning".equals(curStatus)) {
return Map.of("error", "cannot_suspend_while_provisioning");
}
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
p.put("reason", reason);
sqlSession.update("provisioning.deactivateCompany", p);
companyResolver.invalidate((String) company.get("subdomain"));
log.info("[CompanyLifecycle] deactivated company={} reason={}", companyCode, reason);
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", "suspended");
out.put("subdomain", company.get("subdomain"));
return out;
} finally {
restoreCtx(prevCtx);
}
}
public Map<String, Object> reactivate(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> company = getCompany(companyCode);
if (company == null) return Map.of("error", "not_found");
String curStatus = (String) company.get("db_status");
if (!"suspended".equals(curStatus)) {
return Map.of("error", "not_suspended", "current_status", curStatus);
}
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
sqlSession.update("provisioning.reactivateCompany", p);
companyResolver.invalidate((String) company.get("subdomain"));
log.info("[CompanyLifecycle] reactivated company={}", companyCode);
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", "active");
out.put("subdomain", company.get("subdomain"));
return out;
} finally {
restoreCtx(prevCtx);
}
}
/**
* 영구 삭제. DROP DATABASE → COMPANY_MNG row DELETE.
* DROP 이 실패해도 메타 row 는 남겨둠 (사용자가 수동 청소 후 재시도 하도록).
*/
public Map<String, Object> delete(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> company = getCompany(companyCode);
if (company == null) return Map.of("error", "not_found");
String dbName = (String) company.get("db_name");
String subdomain = (String) company.get("subdomain");
// 1. DROP DATABASE (실패해도 로그만, 예외 안 남) — dropDatabase 가 이미 그렇게 동작
databaseCreator.dropDatabase(dbName);
// 2. 메타 row 삭제
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
int affected = sqlSession.delete("provisioning.deleteCompany", p);
companyResolver.invalidate(subdomain);
log.warn("[CompanyLifecycle] ⚠ PERMANENTLY DELETED company={} db={} (rows_removed={})",
companyCode, dbName, affected);
Map<String, Object> out = new LinkedHashMap<>();
out.put("company_code", companyCode);
out.put("db_name", dbName);
out.put("subdomain", subdomain);
out.put("meta_rows_removed", affected);
return out;
} finally {
restoreCtx(prevCtx);
}
}
private static void restoreCtx(String prev) {
if (prev == null) DbContextHolder.clear();
else DbContextHolder.set(prev);
}
}
@@ -0,0 +1,54 @@
package com.erp.provisioning;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 테넌트 DB 의 user_info 를 조회해서 회사 구성원 리스트를 반환.
* 회사 관리 화면의 "구성원" 탭 전용.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyMembersService {
private final TenantDbSettings settings;
public List<Map<String, Object>> listMembers(String dbName) {
List<Map<String, Object>> out = new ArrayList<>();
try (Connection c = DriverManager.getConnection(
settings.buildJdbcUrl(dbName), settings.username(), settings.password());
PreparedStatement ps = c.prepareStatement(
"SELECT user_id, user_name, user_type, status, created_date " +
"FROM user_info " +
"ORDER BY " +
" CASE user_type WHEN 'COMPANY_ADMIN' THEN 0 WHEN 'ADMIN' THEN 1 ELSE 2 END, " +
" created_date DESC");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("user_id", rs.getString("user_id"));
row.put("user_name", rs.getString("user_name"));
row.put("user_type", rs.getString("user_type"));
row.put("status", rs.getString("status"));
row.put("created_date", rs.getTimestamp("created_date"));
out.add(row);
}
} catch (SQLException e) {
log.error("[CompanyMembers] list failed db={} err={}", dbName, e.getMessage());
}
return out;
}
}
@@ -0,0 +1,227 @@
package com.erp.provisioning;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 회사 관리 화면 라이프사이클 엔드포인트.
*
* 프로비저닝(생성) 은 {@link ProvisioningController} 가 담당. 이 컨트롤러는 그 이후의
* 관리 액션 — 비활성/재활성/삭제, 관리자 계정 관리, 구성원 조회, 템플릿 재복제, 감사 로그.
*
* 전부 SUPER_ADMIN 전용 ({@link SuperAdminGuard}).
*/
@RestController
@RequestMapping("/api/admin/provisioning")
@RequiredArgsConstructor
@Slf4j
public class CompanyMgmtController {
private final SuperAdminGuard guard;
private final CompanyLifecycleService lifecycleService;
private final CompanyAdminService adminService;
private final CompanyMembersService membersService;
private final CompanyTemplatesService templatesService;
private final CompanyAuditLogService auditLogService;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
// ─────────────── 관리자 계정 ───────────────
@GetMapping("/companies/{companyCode}/admin")
public ResponseEntity<Map<String, Object>> getAdmin(HttpServletRequest req, @PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
Map<String, Object> admin = adminService.getAdmin(dbName);
admin.put("company_code", companyCode);
admin.put("db_name", dbName);
return ResponseEntity.ok(admin);
}
@PostMapping("/companies/{companyCode}/admin/reset-password")
public ResponseEntity<Map<String, Object>> resetAdminPassword(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
Map<String, Object> result = adminService.resetAdminPassword(dbName);
String actor = guard.actorUserId(req);
if (result.containsKey("error")) {
auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET,
dbName, Map.of("error_kind", result.get("error")), (String) result.get("error"));
return ResponseEntity.status(500).body(result);
}
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET,
(String) result.get("admin_user_id"), Map.of("db_name", dbName));
return ResponseEntity.ok()
.header("Cache-Control", "no-store")
.header("Pragma", "no-cache")
.body(result);
}
// ─────────────── 구성원 ───────────────
@GetMapping("/companies/{companyCode}/members")
public ResponseEntity<Map<String, Object>> listMembers(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
List<Map<String, Object>> members = membersService.listMembers(dbName);
Map<String, Object> out = new LinkedHashMap<>();
out.put("company_code", companyCode);
out.put("db_name", dbName);
out.put("members", members);
out.put("total", members.size());
return ResponseEntity.ok(out);
}
// ─────────────── 템플릿 ───────────────
@GetMapping("/companies/{companyCode}/installed-groups")
public ResponseEntity<List<Map<String, Object>>> installedGroups(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
return ResponseEntity.ok(templatesService.listInstalledGroups(companyCode));
}
@PostMapping("/companies/{companyCode}/re-copy")
public ResponseEntity<Map<String, Object>> recopyTemplates(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody Map<String, Object> body) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dstDb = (String) company.get("db_name");
String srcDb = resolveMetaDbName();
@SuppressWarnings("unchecked")
List<String> groups = (List<String>) body.getOrDefault("selected_groups", List.of());
if (groups.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "selected_groups required"));
}
Map<String, Object> result = templatesService.recopy(srcDb, dstDb, groups);
String actor = guard.actorUserId(req);
List<?> errors = (List<?>) result.get("errors");
if (errors != null && !errors.isEmpty()) {
auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY,
dstDb, Map.of("selected_groups", groups, "errors", errors),
"partial_failure: " + errors.size() + " table(s)");
} else {
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY, dstDb,
Map.of("selected_groups", groups, "total_inserted", result.get("total_inserted")));
}
return ResponseEntity.ok(result);
}
// ─────────────── 라이프사이클 ───────────────
@PatchMapping("/companies/{companyCode}/status")
public ResponseEntity<Map<String, Object>> changeStatus(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody Map<String, Object> body) {
guard.enforce(req);
String target = (String) body.get("status");
String reason = (String) body.get("reason");
if (target == null) {
return ResponseEntity.badRequest().body(Map.of("error", "status required"));
}
String actor = guard.actorUserId(req);
Map<String, Object> result;
String action;
if ("suspended".equals(target)) {
result = lifecycleService.deactivate(companyCode, reason);
action = CompanyAuditLogService.ACTION_DEACTIVATE;
} else if ("active".equals(target)) {
result = lifecycleService.reactivate(companyCode);
action = CompanyAuditLogService.ACTION_REACTIVATE;
} else {
return ResponseEntity.badRequest().body(Map.of("error", "unsupported status: " + target));
}
if (result.containsKey("error")) {
auditLogService.logFailure(companyCode, actor, action, null,
Map.of("target_status", target, "reason", reason == null ? "" : reason),
String.valueOf(result.get("error")));
return ResponseEntity.badRequest().body(result);
}
auditLogService.log(companyCode, actor, action, (String) result.get("subdomain"),
Map.of("target_status", target, "reason", reason == null ? "" : reason));
return ResponseEntity.ok(result);
}
/**
* 영구 삭제. 안전장치: 요청 본문에 {@code confirm_subdomain} 을 담아 보내야 함 (프론트 타이핑 확인).
* 이 값이 현재 서브도메인과 정확히 일치해야 실행.
*/
@DeleteMapping("/companies/{companyCode}")
public ResponseEntity<Map<String, Object>> deleteCompany(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String currentSub = (String) company.get("subdomain");
String confirmSub = body == null ? null : (String) body.get("confirm_subdomain");
if (confirmSub == null || !confirmSub.equals(currentSub)) {
return ResponseEntity.status(400).body(Map.of(
"error", "confirmation_mismatch",
"message", "confirm_subdomain 이 실제 서브도메인과 일치해야 함"));
}
String actor = guard.actorUserId(req);
Map<String, Object> result = lifecycleService.delete(companyCode);
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_DELETE,
(String) result.get("db_name"),
Map.of("subdomain", result.get("subdomain"),
"meta_rows_removed", result.get("meta_rows_removed")));
return ResponseEntity.ok(result);
}
// ─────────────── 감사 로그 ───────────────
@GetMapping("/companies/{companyCode}/audit-log")
public ResponseEntity<Map<String, Object>> companyAuditLog(HttpServletRequest req,
@PathVariable String companyCode,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int limit) {
guard.enforce(req);
return ResponseEntity.ok(auditLogService.listByCompany(companyCode, page, limit));
}
@GetMapping("/audit-log")
public ResponseEntity<Map<String, Object>> globalAuditLog(HttpServletRequest req,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String action) {
guard.enforce(req);
return ResponseEntity.ok(auditLogService.listAll(page, limit, action));
}
// ─────────────── helpers ───────────────
/** jdbc:postgresql://host:port/{db_name}?... 에서 db_name 추출. */
private String resolveMetaDbName() {
int slash = metaJdbcUrl.lastIndexOf('/');
if (slash < 0) return "invyone";
String tail = metaJdbcUrl.substring(slash + 1);
int q = tail.indexOf('?');
return q < 0 ? tail : tail.substring(0, q);
}
}
@@ -47,6 +47,8 @@ public class CompanyProvisioningService {
private final AdminAccountCreator adminCreator;
private final CompanyResolver companyResolver;
private final SqlSession sqlSession;
private final CompanyTemplatesService templatesService;
private final CompanyAuditLogService auditLogService;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
@@ -121,6 +123,9 @@ public class CompanyProvisioningService {
String dbPrefix = (String) req.get("db_prefix");
String initialPassword = (String) req.get("initial_password");
String companyName = (String) req.get("company_name");
// force_password_change: 기본 true (첫 로그인 시 비밀번호 변경 강제). 프론트에서 명시적으로 false 주면 그대로 반영.
Object fpc = req.get("force_password_change");
boolean forcePasswordChange = fpc == null || Boolean.parseBoolean(fpc.toString());
@SuppressWarnings("unchecked")
List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
@@ -170,7 +175,7 @@ public class CompanyProvisioningService {
String adminId = dbPrefix + "_admin";
String adminName = (companyName == null ? companyCode : companyName) + " 관리자";
dst.setAutoCommit(true);
adminCreator.createCompanyAdmin(dst, companyCode, adminId, initialPassword, adminName);
adminCreator.createCompanyAdmin(dst, companyCode, adminId, initialPassword, adminName, forcePasswordChange);
}
// Step 6: Finalize
@@ -179,6 +184,10 @@ public class CompanyProvisioningService {
upd.put("company_code", companyCode);
upd.put("db_status", "active");
sqlSession.update("provisioning.updateDbStatus", upd);
// installed_groups 메타 저장
templatesService.saveInstalledGroups(companyCode, selectedGroupIds);
companyResolver.invalidate(subdomain);
registry.update(jobId, s -> {
@@ -187,6 +196,13 @@ public class CompanyProvisioningService {
});
log.info("[Provisioning] ✅ completed: company={} db={} subdomain={}", companyCode, dbName, subdomain);
// 감사 로그 — 성공
Map<String, Object> auditDetails = new HashMap<>();
auditDetails.put("db_name", dbName);
auditDetails.put("subdomain", subdomain);
auditDetails.put("selected_groups", selectedGroupIds);
auditLogService.log(companyCode, null, CompanyAuditLogService.ACTION_CREATE, dbName, auditDetails);
} catch (Exception e) {
String failedStep = registry.get(jobId).getCurrentStep();
log.error("[Provisioning] ❌ FAILED at {} for {} ({})", failedStep, dbName, e.getMessage(), e);
@@ -206,6 +222,13 @@ public class CompanyProvisioningService {
} catch (Exception ex) {
log.error("[Provisioning] failed marking db_status=failed: {}", ex.getMessage());
}
// 감사 로그 — 실패
Map<String, Object> auditDetails = new HashMap<>();
auditDetails.put("db_name", dbName);
auditDetails.put("subdomain", subdomain);
auditDetails.put("failed_step", failedStep);
auditLogService.logFailure(companyCode, null, CompanyAuditLogService.ACTION_CREATE_FAILED,
dbName, auditDetails, e.getMessage());
}
}
@@ -0,0 +1,232 @@
package com.erp.provisioning;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDbSettings;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 회사에 설치된 TableGroup 조회 + 템플릿 재복제.
*
* 재복제 정책 (MVP): {@code INSERT ... ON CONFLICT DO NOTHING}.
* 메타 DB 의 공통 템플릿(company_code IN ('*','TEMPLATE') or NULL) row 들을
* tenant DB 에 추가 — **이미 있는 row 는 스킵**, 새로 추가된 것만 반영.
* 사용자 커스터마이징 데이터는 덮어쓰지 않음.
*
* installed_groups 는 {@code COMPANY_MNG.INSTALLED_GROUPS} (JSONB) 에 저장.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyTemplatesService {
private final SqlSession sqlSession;
private final TenantDbSettings settings;
private final ObjectMapper objectMapper;
/**
* 설치된 그룹 id 조회. 메타 DB 의 COMPANY_MNG.INSTALLED_GROUPS 에서 읽어옴.
* 빈 배열이면 legacy 회사 → 필수 그룹만 최소 반환.
*/
public List<Map<String, Object>> listInstalledGroups(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> p = Map.of("company_code", companyCode);
Map<String, Object> row = sqlSession.selectOne("provisioning.selectCompanyByCode", p);
if (row == null) return List.of();
List<String> ids = parseJsonArray(row.get("installed_groups"));
// 빈 배열 이면 legacy — 필수 그룹은 반드시 깔렸다고 가정
if (ids.isEmpty()) {
ids = TableGroup.requiredGroups().stream().map(TableGroup::name).toList();
}
List<Map<String, Object>> out = new ArrayList<>();
Set<String> idSet = new HashSet<>(ids);
for (TableGroup g : TableGroup.values()) {
Map<String, Object> m = new LinkedHashMap<>(g.toMap());
m.put("installed", idSet.contains(g.name()));
out.add(m);
}
return out;
} finally {
restoreCtx(prevCtx);
}
}
/**
* provisioning 시 initiate() 에서 호출 — selected + required 를 JSONB 로 저장.
*/
public void saveInstalledGroups(String companyCode, List<String> selectedGroupIds) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Set<String> all = new HashSet<>(selectedGroupIds == null ? List.of() : selectedGroupIds);
TableGroup.requiredGroups().forEach(g -> all.add(g.name()));
// 유효한 그룹 id 만
List<String> validated = all.stream()
.filter(id -> TableGroup.parse(id) != null)
.sorted()
.toList();
String json = objectMapper.writeValueAsString(validated);
Map<String, Object> p = new LinkedHashMap<>();
p.put("company_code", companyCode);
p.put("installed_groups_json", json);
sqlSession.update("provisioning.updateInstalledGroups", p);
log.info("[CompanyTemplates] saved installed_groups for {}: {}", companyCode, validated);
} catch (Exception e) {
log.error("[CompanyTemplates] saveInstalledGroups failed {}: {}", companyCode, e.getMessage());
} finally {
restoreCtx(prevCtx);
}
}
/**
* 템플릿 재복제. 선택된 그룹의 테이블에서 공통 template row 를 메타→tenant 로 추가.
* ON CONFLICT DO NOTHING 으로 idempotent. 사용자 데이터는 건드리지 않음.
*
* @return { tables: [{name, inserted, skipped}], total_inserted, errors }
*/
public Map<String, Object> recopy(String srcDbName, String dstDbName, List<String> selectedGroupIds) {
List<String> tables = gatherTables(selectedGroupIds);
Map<String, Object> result = new LinkedHashMap<>();
List<Map<String, Object>> perTable = new ArrayList<>();
int totalInserted = 0;
List<String> errors = new ArrayList<>();
try (Connection src = DriverManager.getConnection(
settings.buildJdbcUrl(srcDbName), settings.username(), settings.password());
Connection dst = DriverManager.getConnection(
settings.buildJdbcUrl(dstDbName), settings.username(), settings.password())) {
src.setAutoCommit(true);
dst.setAutoCommit(false);
for (String table : tables) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("table", table);
try {
int inserted = copyTableIdempotent(src, dst, table);
row.put("inserted", inserted);
row.put("status", "ok");
totalInserted += inserted;
} catch (SQLException e) {
row.put("inserted", 0);
row.put("status", "error");
row.put("error", e.getMessage());
errors.add(table + ": " + e.getMessage());
log.warn("[CompanyTemplates] recopy table={} failed: {}", table, e.getMessage());
}
perTable.add(row);
}
dst.commit();
} catch (SQLException e) {
errors.add("connection: " + e.getMessage());
log.error("[CompanyTemplates] recopy top-level failure {}: {}", dstDbName, e.getMessage());
}
result.put("tables", perTable);
result.put("total_inserted", totalInserted);
result.put("errors", errors);
result.put("selected_groups", selectedGroupIds);
return result;
}
/** 복사 + ON CONFLICT DO NOTHING. inserted row 수 반환. */
private int copyTableIdempotent(Connection src, Connection dst, String table) throws SQLException {
boolean hasCompanyCode = columnExists(src, table, "company_code");
List<String> cols = listColumns(src, table);
if (cols.isEmpty()) return 0;
String where = hasCompanyCode ? " WHERE company_code IS NULL OR company_code IN ('*', 'TEMPLATE')" : "";
String quotedCols = cols.stream().map(c -> "\"" + c + "\"").collect(Collectors.joining(","));
String selectSql = "SELECT " + quotedCols + " FROM \"" + table + "\"" + where;
String placeholders = cols.stream().map(c -> "?").collect(Collectors.joining(","));
String insertSql = "INSERT INTO \"" + table + "\" (" + quotedCols + ") VALUES (" + placeholders + ") ON CONFLICT DO NOTHING";
int inserted = 0;
try (PreparedStatement sel = src.prepareStatement(selectSql);
ResultSet rs = sel.executeQuery();
PreparedStatement ins = dst.prepareStatement(insertSql)) {
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
while (rs.next()) {
for (int i = 1; i <= colCount; i++) ins.setObject(i, rs.getObject(i));
inserted += ins.executeUpdate();
}
}
return inserted;
}
private List<String> gatherTables(List<String> selectedGroupIds) {
if (selectedGroupIds == null || selectedGroupIds.isEmpty()) return List.of();
List<String> result = new ArrayList<>();
for (String id : selectedGroupIds) {
TableGroup g = TableGroup.parse(id);
if (g != null) result.addAll(g.getTables());
}
return result;
}
private List<String> listColumns(Connection conn, String table) throws SQLException {
List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(
"SELECT column_name FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) " +
"ORDER BY ordinal_position")) {
ps.setString(1, table);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) cols.add(rs.getString(1));
}
}
return cols;
}
private boolean columnExists(Connection conn, String table, String col) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT 1 FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) AND lower(column_name)=lower(?)")) {
ps.setString(1, table);
ps.setString(2, col);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
private List<String> parseJsonArray(Object raw) {
if (raw == null) return List.of();
try {
return objectMapper.readValue(raw.toString(), new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("[CompanyTemplates] parse installed_groups failed: {}", e.getMessage());
return List.of();
}
}
private static void restoreCtx(String prev) {
if (prev == null) DbContextHolder.clear();
else DbContextHolder.set(prev);
}
}
@@ -0,0 +1,40 @@
package com.erp.provisioning;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
/**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
*
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
*
* JwtAuthenticationFilter 가 request.getAttribute("user_type"), "user_id" 를 세팅한다.
*/
@Component
@Slf4j
public class SuperAdminGuard {
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
public void enforce(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
log.warn("[ProvisioningGuard] anonymous access allowed in dev mode");
return;
}
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
}
/** 감사 로그에 기록할 actor user_id. 개발모드에서 JWT 없으면 "dev-anonymous". */
public String actorUserId(HttpServletRequest request) {
String userId = (String) request.getAttribute("user_id");
return userId == null ? "dev-anonymous" : userId;
}
}
@@ -0,0 +1,95 @@
package com.erp.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
/**
* JWT 의 force_password_change 클레임이 true 면 비밀번호 변경 완료 전까지
* 일부 허용 경로 외 모든 API 요청을 403 으로 차단한다.
*
* 허용 경로 (이 경로들만 진행 가능):
* - POST /api/auth/change-password — 실제 변경 API
* - POST /api/auth/logout — 사용자가 포기하고 로그아웃
* - POST /api/auth/refresh — 토큰 만료 시 재발급 (플래그는 그대로 유지됨)
* - GET /api/auth/me — AuthGuard 가 현재 사용자 상태 조회용
* - GET /api/auth/status — 세션 상태 체크
* - OPTIONS ** — CORS preflight 는 필터 통과 필요
*
* JwtAuthenticationFilter 다음에 등록된다. (SecurityConfig)
*/
@Slf4j
@RequiredArgsConstructor
public class ForcePasswordChangeGuardFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private static final Set<String> ALLOWED_PATHS = Set.of(
"/api/auth/change-password",
"/api/auth/logout",
"/api/auth/refresh",
"/api/auth/me",
"/api/auth/status"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// CORS preflight 는 그대로 통과
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
String path = request.getRequestURI();
// /api/** 가 아니면 이 가드와 무관
if (path == null || !path.startsWith("/api/")) {
filterChain.doFilter(request, response);
return;
}
// 토큰이 없으면 비로그인 요청 — 여기선 막지 않는다. (기존 permitAll 동작 유지)
String token = resolveToken(request);
if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
filterChain.doFilter(request, response);
return;
}
if (!jwtTokenProvider.isForcePasswordChange(token)) {
filterChain.doFilter(request, response);
return;
}
if (ALLOWED_PATHS.contains(path)) {
filterChain.doFilter(request, response);
return;
}
// 강제 변경 대기 상태 → 허용되지 않은 경로는 403
log.warn("[ForcePwGuard] blocked: path={} (force_password_change=true)", path);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Cache-Control", "no-store");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
"success", false,
"errorCode", "PASSWORD_CHANGE_REQUIRED",
"message", "비밀번호 변경이 필요합니다. /change-password 에서 먼저 변경해주세요."
));
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
@@ -31,6 +31,9 @@ public class JwtTokenProvider {
*/
public String generateToken(Map<String, Object> personBean) {
Date now = new Date();
Object fpc = personBean.get("force_password_change");
boolean forcePasswordChange = fpc instanceof Boolean ? (Boolean) fpc
: fpc != null && Boolean.parseBoolean(fpc.toString());
return Jwts.builder()
.claim("user_id", personBean.get("user_id"))
.claim("user_name", personBean.get("user_name"))
@@ -39,6 +42,7 @@ public class JwtTokenProvider {
.claim("company_name", personBean.get("company_name"))
.claim("user_type", personBean.get("user_type"))
.claim("user_type_name", personBean.get("user_type_name"))
.claim("force_password_change", forcePasswordChange)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.audience().add("PMS-Users").and()
@@ -47,6 +51,16 @@ public class JwtTokenProvider {
.compact();
}
/** JWT 의 force_password_change claim. 없는 토큰(legacy)은 false 로 간주. */
public boolean isForcePasswordChange(String token) {
try {
Boolean v = getClaims(token).get("force_password_change", Boolean.class);
return Boolean.TRUE.equals(v);
} catch (Exception e) {
return false;
}
}
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
@@ -58,6 +58,12 @@ public class SecurityConfig {
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
// JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조.
.addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider),
JwtAuthenticationFilter.class)
// TenantConsistencyGuardFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
TenantConsistencyGuardFilter.class)
// Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅.
// JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨.
.addFilterBefore(
@@ -0,0 +1,130 @@
package com.erp.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
/**
* 크로스-테넌트 요청 차단.
*
* 검증:
* JWT 가 있는 요청 && Host 가 테넌트 서브도메인 → JWT.company_code == tenant.company_code 여야 함.
*
* 동작:
* 1. OPTIONS (CORS preflight) → 통과
* 2. /api/** 가 아닌 요청 → 통과
* 3. JWT 없는 요청 → 통과 (로그인/회원가입 등)
* 4. JWT 있음 + 서브도메인 있음 + resolve 실패 → 거부 (악의적 요청 의심)
* 5. JWT 있음 + tenant attribute 없음 (메인 도메인) → 통과 (SUPER_ADMIN 전역 API 등)
* 6. JWT.company_code == tenant.company_code → 통과
* 7. JWT.company_code == "*" → 제한된 SUPER_ADMIN pre-switch 경로만 통과
* 8. 나머지 → 거부
*
* 필터 순서: SubdomainResolverFilter → JwtAuthenticationFilter → **여기** → ForcePasswordChangeGuardFilter
*/
@Slf4j
@RequiredArgsConstructor
public class TenantConsistencyGuardFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
/**
* SUPER_ADMIN 가 company_code="*" 로 로그인한 직후, 실제 회사로 switch 하기 전에
* 테넌트 서브도메인에서 허용해야 하는 최소 경로들.
* 여기에 실제 업무 API 를 넣지 말 것 — "*" 는 어떤 회사 DB 도 대표하지 않는 상태.
*/
private static final Set<String> STAR_ALLOWED_PATHS = Set.of(
"/api/auth/switch-company",
"/api/auth/me",
"/api/auth/status",
"/api/auth/logout",
"/api/auth/refresh"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
chain.doFilter(request, response);
return;
}
String path = request.getRequestURI();
if (path == null || !path.startsWith("/api/")) {
chain.doFilter(request, response);
return;
}
String token = resolveToken(request);
if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
// 로그인/회원가입 등 JWT 없는 요청은 통과
chain.doFilter(request, response);
return;
}
String jwtCompanyCode = jwtTokenProvider.getCompanyCode(token);
String tenantCompanyCode = (String) request.getAttribute("tenant_company_code");
Boolean attempted = (Boolean) request.getAttribute("tenant_resolve_attempted");
// (4) SUPER_ADMIN pre-switch escape — "*" 토큰 + 허용 경로는 tenant resolve 결과와 무관하게 통과.
// 미등록/stale 서브도메인에 잘못 붙어도 switch-company / logout 으로 회복할 수 있어야 함.
if ("*".equals(jwtCompanyCode) && STAR_ALLOWED_PATHS.contains(path)) {
chain.doFilter(request, response);
return;
}
// (5) 서브도메인은 있는데 resolve 실패 + JWT 있음 → 의심스러움. 거부.
if (Boolean.TRUE.equals(attempted) && tenantCompanyCode == null) {
deny(response, path, "TENANT_NOT_RESOLVED",
"등록되지 않은 서브도메인입니다.");
return;
}
// (6) tenant attribute 자체가 없음 → 메인 도메인. 이 가드는 스킵.
if (tenantCompanyCode == null) {
chain.doFilter(request, response);
return;
}
// (7) 정상 일치
if (tenantCompanyCode.equals(jwtCompanyCode)) {
chain.doFilter(request, response);
return;
}
// (8) 거부
log.warn("[TenantGuard] cross-tenant blocked: path={}, jwt={}, tenant={}",
path, jwtCompanyCode, tenantCompanyCode);
deny(response, path, "CROSS_TENANT_REJECTED",
"로그인한 회사와 접속 도메인이 일치하지 않습니다.");
}
private void deny(HttpServletResponse response, String path, String errorCode, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Cache-Control", "no-store");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
"success", false,
"errorCode", errorCode,
"message", message
));
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
@@ -125,6 +125,12 @@ public class AuthService extends BaseService {
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);
@@ -134,6 +140,7 @@ public class AuthService extends BaseService {
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 동일 로직)
@@ -199,19 +206,82 @@ public class AuthService extends BaseService {
data.put("token", token);
data.put("first_menu_path", firstMenuPath);
data.put("pop_landing_path", popLandingPath);
data.put("force_password_change", forcePasswordChange);
log.info("로그인 성공: {} ({})", userId, remoteAddr);
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", claims.get("user_id", String.class));
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));
@@ -219,6 +289,24 @@ public class AuthService extends BaseService {
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<>();
@@ -259,6 +347,13 @@ public class AuthService extends BaseService {
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", ""));
@@ -273,6 +368,7 @@ public class AuthService extends BaseService {
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;
}
@@ -291,9 +387,11 @@ public class AuthService extends BaseService {
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;
}
@@ -10,7 +10,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 서브도메인 → tenant DB 이름 매핑.
* 서브도메인 → (db_name, company_code) 매핑.
* 메타 DB (COMPANY_MNG) 에서 조회, ConcurrentHashMap 으로 캐시.
* null 결과(= 매핑 없음)는 캐시하지 않음 — 신규 회사 프로비저닝 직후 바로 반영되게.
*/
@@ -21,26 +21,45 @@ public class CompanyResolver {
private final SqlSession sqlSession;
private final Map<String, String> cache = new ConcurrentHashMap<>();
/** subdomain → Resolved (db_name, company_code). 둘 다 묶어 한 번의 쿼리로 캐시. */
public record Resolved(String dbName, String companyCode) {}
private final Map<String, Resolved> cache = new ConcurrentHashMap<>();
/**
* @return tenant DB 이름, 매핑 없으면 null.
* @return 매핑 있으면 Resolved, 없으면 null.
*/
public String resolveDbName(String subdomain) {
public Resolved resolve(String subdomain) {
if (subdomain == null || subdomain.isBlank()) return null;
String cached = cache.get(subdomain);
Resolved cached = cache.get(subdomain);
if (cached != null) return cached;
Map<String, Object> params = new HashMap<>();
params.put("subdomain", subdomain);
String dbName = sqlSession.selectOne("tenant.resolveDbNameBySubdomain", params);
Map<String, Object> row = sqlSession.selectOne("tenant.resolveTenantBySubdomain", params);
if (row == null) return null;
if (dbName != null) {
cache.put(subdomain, dbName);
log.debug("[CompanyResolver] resolved: {} -> {}", subdomain, dbName);
}
return dbName;
String dbName = (String) row.get("db_name");
String companyCode = (String) row.get("company_code");
if (dbName == null) return null;
Resolved r = new Resolved(dbName, companyCode);
cache.put(subdomain, r);
log.debug("[CompanyResolver] resolved: {} -> db={}, company={}", subdomain, dbName, companyCode);
return r;
}
/** 하위 호환: db_name 만 필요할 때 */
public String resolveDbName(String subdomain) {
Resolved r = resolve(subdomain);
return r == null ? null : r.dbName();
}
/** company_code 만 필요할 때 */
public String resolveCompanyCode(String subdomain) {
Resolved r = resolve(subdomain);
return r == null ? null : r.companyCode();
}
/** 특정 서브도메인 캐시 무효화 (회사 수정/삭제 시) */
@@ -43,19 +43,24 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
if (subdomain == null) {
DbContextHolder.setMeta();
// 메인 도메인/IP/localhost 요청은 tenant attribute 없음 — 후속 필터가 스킵
} else {
// resolve 쿼리 자체는 메타 DB 를 타야 하므로 먼저 META 로 세팅.
DbContextHolder.setMeta();
String dbName = companyResolver.resolveDbName(subdomain);
CompanyResolver.Resolved resolved = companyResolver.resolve(subdomain);
if (dbName == null) {
if (resolved == null) {
log.debug("[Tenant] unknown subdomain '{}' → META fallback (host={})", subdomain, host);
// META 유지
// META 유지. TenantConsistencyGuardFilter 가 JWT 붙은 요청이면 거부.
request.setAttribute("tenant_resolve_attempted", Boolean.TRUE);
} else {
ensureTenantPool(dbName);
DbContextHolder.set(dbName);
log.info("[Tenant] routed: subdomain={} → dbName={} (path={})",
subdomain, dbName, request.getRequestURI());
ensureTenantPool(resolved.dbName());
DbContextHolder.set(resolved.dbName());
request.setAttribute("tenant_db_name", resolved.dbName());
request.setAttribute("tenant_company_code", resolved.companyCode());
request.setAttribute("tenant_resolve_attempted", Boolean.TRUE);
log.info("[Tenant] routed: subdomain={} → db={}, company={} (path={})",
subdomain, resolved.dbName(), resolved.companyCode(), request.getRequestURI());
}
}