회사 관리 기능 확장 + 테넌트/비번 보안 하드닝
- 첫 로그인 비번 강제 변경 (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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user