로그인 실패 계정잠금 예외(SER-001 #24) — 최고관리자 완전 잠김 방지
관리자 계정이 5회 실패로 잠기면 해제 화면도 로그인이 있어야 들어갈 수 있어 아무도 풀 수 없는 상태가 될 수 있었다. 지정 계정을 LOGIN_LOCK_EXEMPT 화이트리스트로 등록하면 실패 잠금 판정만 면제되고, LOGIN_HISTORY 감사이력과 2단계 인증(OTP)은 예외 없이 그대로 적용된다. 캐시는 fail-closed로 조회 실패 시 전 계정 정상 잠금 판정을 유지한다. 접근통제 정책 화면에 '잠금 예외 계정' 섹션을 추가해 등록/해제하며, 같은 화면의 '잠금 해제 방식'은 실사용 중인 단일 옵션만 남기도록 단순화했다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
363138eef5
commit
e35aa9db06
@ -8,6 +8,7 @@ import org.springframework.stereotype.Controller;
|
|||||||
import org.springframework.ui.ModelMap;
|
import org.springframework.ui.ModelMap;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import egovframework.com.cmm.LoginVO;
|
import egovframework.com.cmm.LoginVO;
|
||||||
import egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor;
|
import egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor;
|
||||||
@ -24,6 +25,7 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
|||||||
* <li>{@code /mnt/privacy/accessPolicyUpdate.do} — 정책 저장(AJAX/JSON)</li>
|
* <li>{@code /mnt/privacy/accessPolicyUpdate.do} — 정책 저장(AJAX/JSON)</li>
|
||||||
* <li>{@code /mnt/session/extend.do} — 세션 연장(만료 임박 안내에서 호출)</li>
|
* <li>{@code /mnt/session/extend.do} — 세션 연장(만료 임박 안내에서 호출)</li>
|
||||||
* <li>{@code /mnt/session/status.do} — 로그인 경과/잔여시간 조회(상단 표시 갱신용)</li>
|
* <li>{@code /mnt/session/status.do} — 로그인 경과/잔여시간 조회(상단 표시 갱신용)</li>
|
||||||
|
* <li>{@code /mnt/privacy/lockExempt*.do} — 잠금 예외 계정 조회/등록/해제(#24)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p><b>경로 선정 근거</b> — 세션 연장·상태 조회는 전체관리자 전용이 아니라
|
* <p><b>경로 선정 근거</b> — 세션 연장·상태 조회는 전체관리자 전용이 아니라
|
||||||
@ -45,6 +47,9 @@ public class AccessControlPolicyController {
|
|||||||
@Resource(name = "accessControlPolicyService")
|
@Resource(name = "accessControlPolicyService")
|
||||||
private AccessControlPolicyService accessControlPolicyService;
|
private AccessControlPolicyService accessControlPolicyService;
|
||||||
|
|
||||||
|
@Resource(name = "loginLockExemptService")
|
||||||
|
private LoginLockExemptService loginLockExemptService;
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────── 정책 화면
|
// ────────────────────────────────────────────────────────── 정책 화면
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -136,6 +141,94 @@ public class AccessControlPolicyController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────── 잠금 예외 계정(#24)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 예외 계정 목록(JSON). 정책 화면의 '잠금 예외 계정' 섹션이 진입 시 호출한다.
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/privacy/lockExemptList.do")
|
||||||
|
public String lockExemptList(ModelMap model) throws Exception {
|
||||||
|
if (handlerUser() == null) {
|
||||||
|
return denyJson(model);
|
||||||
|
}
|
||||||
|
model.addAttribute("result", "success");
|
||||||
|
model.addAttribute("list", loginLockExemptService.selectExemptList());
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 예외 계정 등록(JSON).
|
||||||
|
*
|
||||||
|
* <p>대상 ID 가 취급자(COMTNEMPLYRINFO)로 실존하는지 서버측에서 확인한다 — 오타로
|
||||||
|
* 존재하지 않는 ID 가 등록되면 "예외를 걸어 뒀다"고 믿는 계정이 실제로는 잠기는
|
||||||
|
* 상황이 되고, 그 사실은 잠긴 뒤에야 드러난다. 저장되는 ID 는 입력값이 아니라
|
||||||
|
* 조회된 표기 그대로의 EMPLYR_ID 다(대소문자 불일치로 캐시 비교가 어긋나지 않게).</p>
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/privacy/lockExemptAdd.do")
|
||||||
|
public String lockExemptAdd(@RequestParam(value = "exemptId", required = false) String exemptId,
|
||||||
|
@RequestParam(value = "exemptResn", required = false) String exemptResn,
|
||||||
|
HttpServletRequest request, ModelMap model) throws Exception {
|
||||||
|
LoginVO loginVO = handlerUser();
|
||||||
|
if (loginVO == null) {
|
||||||
|
return denyJson(model);
|
||||||
|
}
|
||||||
|
if (!isSameOrigin(request)) {
|
||||||
|
return failJson(model, "잘못된 요청 경로입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String id = exemptId == null ? "" : exemptId.trim();
|
||||||
|
String resn = exemptResn == null ? "" : exemptResn.trim();
|
||||||
|
if (id.length() == 0) {
|
||||||
|
return failJson(model, "예외로 등록할 계정 ID를 입력하세요.");
|
||||||
|
}
|
||||||
|
if (id.length() > 20) {
|
||||||
|
return failJson(model, "계정 ID는 20자 이내로 입력하세요.");
|
||||||
|
}
|
||||||
|
if (resn.length() == 0) {
|
||||||
|
return failJson(model, "예외 등록 사유를 입력하세요. 사유는 감사 대응상 필수입니다.");
|
||||||
|
}
|
||||||
|
if (resn.length() > 500) {
|
||||||
|
return failJson(model, "예외 등록 사유는 500자 이내로 입력하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginLockExemptVO found = loginLockExemptService.selectEmplyr(id);
|
||||||
|
if (found == null) {
|
||||||
|
return failJson(model, "존재하지 않는 취급자 계정입니다. 계정 ID를 확인하세요.");
|
||||||
|
}
|
||||||
|
if (loginLockExemptService.isExempt(found.getExemptId())) {
|
||||||
|
return failJson(model, "이미 잠금 예외로 등록된 계정입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginLockExemptVO vo = new LoginLockExemptVO();
|
||||||
|
vo.setExemptId(found.getExemptId());
|
||||||
|
vo.setExemptResn(resn);
|
||||||
|
vo.setRegId(loginVO.getId());
|
||||||
|
loginLockExemptService.insertExempt(vo);
|
||||||
|
|
||||||
|
model.addAttribute("result", "success");
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 잠금 예외 계정 해제(JSON). 해제 즉시 해당 계정은 다시 잠금 판정 대상이 된다. */
|
||||||
|
@RequestMapping(value = "/mnt/privacy/lockExemptDelete.do")
|
||||||
|
public String lockExemptDelete(@RequestParam(value = "exemptId", required = false) String exemptId,
|
||||||
|
HttpServletRequest request, ModelMap model) throws Exception {
|
||||||
|
if (handlerUser() == null) {
|
||||||
|
return denyJson(model);
|
||||||
|
}
|
||||||
|
if (!isSameOrigin(request)) {
|
||||||
|
return failJson(model, "잘못된 요청 경로입니다.");
|
||||||
|
}
|
||||||
|
String id = exemptId == null ? "" : exemptId.trim();
|
||||||
|
if (id.length() == 0) {
|
||||||
|
return failJson(model, "해제할 계정 ID가 없습니다.");
|
||||||
|
}
|
||||||
|
loginLockExemptService.deleteExempt(id);
|
||||||
|
|
||||||
|
model.addAttribute("result", "success");
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────── 세션 상태·연장
|
// ────────────────────────────────────────────────────────── 세션 상태·연장
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 실패 계정잠금 예외 DAO (SER-001 / 개발항목 24).
|
||||||
|
*
|
||||||
|
* <p>statement 는 {@code PrivacyLoginLockExempt_SQL_Mysql.xml}
|
||||||
|
* (namespace {@code LoginLockExemptDAO}), sql-map-config 등록.</p>
|
||||||
|
*
|
||||||
|
* <p>루트 컨텍스트 {@code @Repository} 빈이므로 자식(servlet) 컨텍스트의
|
||||||
|
* 인증 컨트롤러에서도 참조할 수 있다.</p>
|
||||||
|
*/
|
||||||
|
@Repository("loginLockExemptDAO")
|
||||||
|
public class LoginLockExemptDAO extends EgovComAbstractDAO {
|
||||||
|
|
||||||
|
/** 예외 계정ID 전체 조회 — 캐시 적재용(행 수가 한 자릿수 규모라 전건 조회로 충분). */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<String> selectExemptIds() {
|
||||||
|
return list("LoginLockExemptDAO.selectExemptIds", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관리자 화면 목록(사유·등록자·등록일시·대상자 성명 포함). */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<LoginLockExemptVO> selectExemptList() {
|
||||||
|
return list("LoginLockExemptDAO.selectExemptList", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록 대상 ID 가 취급자(COMTNEMPLYRINFO)로 실존하는지 확인. 없으면 null.
|
||||||
|
*
|
||||||
|
* <p>저장된 표기 그대로의 EMPLYR_ID 와 성명을 돌려준다. 등록 시 이 값을 쓰면
|
||||||
|
* 관리자가 대소문자를 다르게 입력해도 캐시의 문자열 비교가 어긋나지 않는다
|
||||||
|
* (MySQL 기본 collation 은 대소문자 무시라 조회는 되지만 자바 비교는 아니다).</p>
|
||||||
|
*/
|
||||||
|
public LoginLockExemptVO selectEmplyr(String exemptId) {
|
||||||
|
return (LoginLockExemptVO) selectByPk("LoginLockExemptDAO.selectEmplyr", exemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 예외 등록. PK 중복은 호출부에서 사전 확인 후 진입. */
|
||||||
|
public void insertExempt(LoginLockExemptVO vo) {
|
||||||
|
insert("LoginLockExemptDAO.insertExempt", vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 예외 해제(행 삭제). */
|
||||||
|
public int deleteExempt(String exemptId) {
|
||||||
|
return delete("LoginLockExemptDAO.deleteExempt", exemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 실패 계정잠금 예외 서비스 (SER-001 / 개발항목 24).
|
||||||
|
*
|
||||||
|
* <p>지정 계정을 인증 실패 <b>잠금 판정</b>에서만 제외한다. 실패 이력 적재와
|
||||||
|
* 2차 인증은 예외 계정에도 그대로 적용된다 — 잠금 예외는 인증 예외가 아니다.</p>
|
||||||
|
*/
|
||||||
|
public interface LoginLockExemptService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 예외 계정 여부(TTL 캐시). 로그인 시도마다 호출되는 지점 전용.
|
||||||
|
*
|
||||||
|
* <p>조회 실패 시 예외를 던지지 않고 {@code false}(예외 아님)를 돌려준다 —
|
||||||
|
* 판단 불가일 때는 잠금이 정상 동작하는 쪽이 안전하다.</p>
|
||||||
|
*
|
||||||
|
* <p>대소문자를 구분하지 않는다(잠금 SQL 의 MySQL collation 과 동일 기준).
|
||||||
|
* 목록은 취급자(COMTNEMPLYRINFO) ID 만 담으므로, 호출부는 {@code userSe='USR'} 인
|
||||||
|
* 경우에만 물어야 한다 — 다른 회원구분과 ID 문자열이 겹치면 엉뚱한 계정의 잠금이 풀린다.</p>
|
||||||
|
*/
|
||||||
|
boolean isExempt(String loginId);
|
||||||
|
|
||||||
|
/** 관리자 화면 목록(사유·등록자·등록일시 포함). DB 직접 조회. */
|
||||||
|
List<LoginLockExemptVO> selectExemptList() throws Exception;
|
||||||
|
|
||||||
|
/** 등록 대상 ID 가 취급자로 실존하는지 확인. 실존하면 저장 표기 그대로의 ID·성명, 없으면 null. */
|
||||||
|
LoginLockExemptVO selectEmplyr(String exemptId) throws Exception;
|
||||||
|
|
||||||
|
/** 예외 등록 + 캐시 무효화. */
|
||||||
|
void insertExempt(LoginLockExemptVO vo) throws Exception;
|
||||||
|
|
||||||
|
/** 예외 해제 + 캐시 무효화. */
|
||||||
|
void deleteExempt(String exemptId) throws Exception;
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
import org.apache.commons.logging.Log;
|
||||||
|
import org.apache.commons.logging.LogFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 실패 계정잠금 예외 서비스 구현 (SER-001 / 개발항목 24).
|
||||||
|
*
|
||||||
|
* <p><b>캐시</b> — {@code EgovLoginController.actionLogin()} 이 로그인 시도마다 두 번
|
||||||
|
* (잠금 선확인·실패 처리) 조회하므로 {@link AccessControlPolicyServiceImpl} 과 동일한
|
||||||
|
* 짧은 TTL(60초) 캐시를 둔다. 등록·삭제 시 즉시 무효화한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>fail-closed — {@link AccessControlPolicyServiceImpl} 과 반대 방향이다.</b>
|
||||||
|
* 접근통제 정책 쪽은 조회에 실패해도 기본값(잠금 5회·30분)을 돌려준다. 그 기본값이
|
||||||
|
* 이미 강화된 값이라 실패가 곧 완화가 아니기 때문이다. 반면 이 화이트리스트는
|
||||||
|
* <b>있으면 잠금이 면제되는</b> 목록이라, 조회 실패를 관대하게 다루면 그 순간
|
||||||
|
* 무차별 대입에 대한 잠금이 통째로 풀린다. 따라서 조회 실패 시에는 빈 집합
|
||||||
|
* (= 예외 계정 없음 = 전 계정 정상 잠금 판정)으로 취급한다. 테이블 미생성 등으로
|
||||||
|
* 계속 실패하는 상황에서 매 로그인마다 DB를 때리지 않도록, 실패한 빈 집합도
|
||||||
|
* 동일 TTL 로 캐싱한다(최대 60초 뒤 자동 재시도).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Service("loginLockExemptService")
|
||||||
|
public class LoginLockExemptServiceImpl implements LoginLockExemptService {
|
||||||
|
|
||||||
|
private static final Log LOG = LogFactory.getLog(LoginLockExemptServiceImpl.class);
|
||||||
|
|
||||||
|
/** 캐시 수명(ms). 등록·삭제 시 즉시 무효화되므로 관리자 체감 지연은 없다. */
|
||||||
|
private static final long CACHE_TTL_MS = 60 * 1000L;
|
||||||
|
|
||||||
|
@Resource(name = "loginLockExemptDAO")
|
||||||
|
private LoginLockExemptDAO loginLockExemptDAO;
|
||||||
|
|
||||||
|
/** 캐시된 예외 ID 집합. 불변 집합 참조만 교체하므로 락 없이 안전(읽기 다수·쓰기 소수). */
|
||||||
|
private volatile Set<String> cachedIds;
|
||||||
|
private volatile long cachedAt;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isExempt(String loginId) {
|
||||||
|
if (loginId == null || loginId.trim().length() == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cachedExemptIds().contains(normalize(loginId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 잠금 판정 SQL(selectlockCheck·selectFailCount·updateFailLogin)은 모두
|
||||||
|
* {@code WHERE EMPLYR_ID = #id#} 로 MySQL 기본 collation(대소문자 무시)에 따라 매칭된다.
|
||||||
|
* 여기서만 자바 문자열을 그대로 비교하면 대소문자를 바꿔 시도하는 것만으로
|
||||||
|
* 화이트리스트를 우회해 실제 계정에 LOCK_YN='Y' 를 찍을 수 있다 — 이 기능의 존재 이유가 무너진다.
|
||||||
|
* 잠금 SQL 과 같은 기준으로 맞춘다.
|
||||||
|
*/
|
||||||
|
private String normalize(String id) {
|
||||||
|
return id.trim().toLowerCase(Locale.ENGLISH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> cachedExemptIds() {
|
||||||
|
Set<String> snapshot = cachedIds;
|
||||||
|
if (snapshot != null && (System.currentTimeMillis() - cachedAt) < CACHE_TTL_MS) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
Set<String> loaded;
|
||||||
|
try {
|
||||||
|
List<String> ids = loginLockExemptDAO.selectExemptIds();
|
||||||
|
loaded = new HashSet<String>();
|
||||||
|
if (ids != null) {
|
||||||
|
for (String id : ids) {
|
||||||
|
if (id != null && id.trim().length() > 0) {
|
||||||
|
loaded.add(normalize(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded = Collections.unmodifiableSet(loaded);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// fail-closed — 예외 없음으로 취급해 잠금 로직이 정상 동작하게 둔다(위 클래스 주석 참고).
|
||||||
|
LOG.error("[개인정보/24] 잠금 예외 목록 조회 실패 — 예외 없음(전 계정 잠금 판정)으로 진행: " + e.getMessage());
|
||||||
|
loaded = Collections.emptySet();
|
||||||
|
}
|
||||||
|
cachedIds = loaded;
|
||||||
|
cachedAt = System.currentTimeMillis();
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<LoginLockExemptVO> selectExemptList() throws Exception {
|
||||||
|
return loginLockExemptDAO.selectExemptList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LoginLockExemptVO selectEmplyr(String exemptId) throws Exception {
|
||||||
|
return loginLockExemptDAO.selectEmplyr(exemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void insertExempt(LoginLockExemptVO vo) throws Exception {
|
||||||
|
loginLockExemptDAO.insertExempt(vo);
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteExempt(String exemptId) throws Exception {
|
||||||
|
loginLockExemptDAO.deleteExempt(exemptId);
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void invalidate() {
|
||||||
|
cachedIds = null;
|
||||||
|
cachedAt = 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 실패 계정잠금 예외 VO — LOGIN_LOCK_EXEMPT 매핑 (SER-001 / 개발항목 24).
|
||||||
|
*
|
||||||
|
* <p>잠금 <b>판정</b>만 면제하는 화이트리스트 한 행이다. 인증 실패 이력(LOGIN_HISTORY)
|
||||||
|
* 적재와 2차 인증(OTP)은 예외 계정에도 그대로 적용된다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (계정잠금 예외 화이트리스트)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class LoginLockExemptVO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 잠금 예외 대상 계정ID(COMTNEMPLYRINFO.EMPLYR_ID). */
|
||||||
|
private String exemptId;
|
||||||
|
|
||||||
|
/** 예외 등록 사유(필수). */
|
||||||
|
private String exemptResn;
|
||||||
|
|
||||||
|
/** 등록한 관리자 계정ID. */
|
||||||
|
private String regId;
|
||||||
|
|
||||||
|
/** 등록일시(화면 표시용 문자열). */
|
||||||
|
private String regDt;
|
||||||
|
|
||||||
|
/** 대상자 성명 — 목록 조회 시 COMTNEMPLYRINFO 조인 결과. */
|
||||||
|
private String userNm;
|
||||||
|
|
||||||
|
public String getExemptId() { return exemptId; }
|
||||||
|
public void setExemptId(String exemptId) { this.exemptId = exemptId; }
|
||||||
|
public String getExemptResn() { return exemptResn; }
|
||||||
|
public void setExemptResn(String exemptResn) { this.exemptResn = exemptResn; }
|
||||||
|
public String getRegId() { return regId; }
|
||||||
|
public void setRegId(String regId) { this.regId = regId; }
|
||||||
|
public String getRegDt() { return regDt; }
|
||||||
|
public void setRegDt(String regDt) { this.regDt = regDt; }
|
||||||
|
public String getUserNm() { return userNm; }
|
||||||
|
public void setUserNm(String userNm) { this.userNm = userNm; }
|
||||||
|
}
|
||||||
@ -28,6 +28,8 @@ import egovframework.com.cmm.EgovWebUtil;
|
|||||||
import egovframework.com.cmm.LoginVO;
|
import egovframework.com.cmm.LoginVO;
|
||||||
import egovframework.com.cmm.annotation.IncludedInfo;
|
import egovframework.com.cmm.annotation.IncludedInfo;
|
||||||
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
||||||
|
import egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor;
|
||||||
|
import egovframework.com.cmm.privacy.policy.LoginLockExemptService;
|
||||||
import egovframework.com.cmm.service.EgovCmmUseService;
|
import egovframework.com.cmm.service.EgovCmmUseService;
|
||||||
import egovframework.com.cmm.service.Globals;
|
import egovframework.com.cmm.service.Globals;
|
||||||
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
||||||
@ -88,6 +90,10 @@ public class EgovLoginController {
|
|||||||
@Resource(name="egovMessageSource")
|
@Resource(name="egovMessageSource")
|
||||||
EgovMessageSource egovMessageSource;
|
EgovMessageSource egovMessageSource;
|
||||||
|
|
||||||
|
/** 개인정보보호(SER-001/#24) 계정 잠금 예외 화이트리스트 — 조회는 TTL 캐시(fail-closed) */
|
||||||
|
@Resource(name = "loginLockExemptService")
|
||||||
|
private LoginLockExemptService loginLockExemptService;
|
||||||
|
|
||||||
/** log */
|
/** log */
|
||||||
protected static final Log LOG = LogFactory.getLog(EgovLoginController.class);
|
protected static final Log LOG = LogFactory.getLog(EgovLoginController.class);
|
||||||
|
|
||||||
@ -184,8 +190,13 @@ public class EgovLoginController {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// 1. 계정 잠금 선(先) 확인 — 잠긴 계정은 인증 시도 전 차단
|
// 1. 계정 잠금 선(先) 확인 — 잠긴 계정은 인증 시도 전 차단
|
||||||
|
// 단, LOGIN_LOCK_EXEMPT 등록 계정은 이 확인 자체를 건너뛴다(#24 break-glass).
|
||||||
|
// 잠금 해제 화면도 관리자 로그인이 있어야 들어갈 수 있어, 최고관리자가 잠기면
|
||||||
|
// 아무도 해제할 수 없는 완전 잠김이 된다. 예외 등록 전에 이미 LOCK_YN='Y' 가
|
||||||
|
// 찍혀 있었더라도 등록 시점부터는 그 값과 무관하게 로그인 시도를 막지 않는다.
|
||||||
try {
|
try {
|
||||||
if (loginVO.getId() != null && !"".equals(loginVO.getId())) {
|
if (loginVO.getId() != null && !"".equals(loginVO.getId())
|
||||||
|
&& !isLockExempt(loginVO)) {
|
||||||
LoginVO lockVo = loginService.selectlockCheck(loginVO);
|
LoginVO lockVo = loginService.selectlockCheck(loginVO);
|
||||||
if (lockVo != null && "Y".equals(lockVo.getLockYn())) {
|
if (lockVo != null && "Y".equals(lockVo.getLockYn())) {
|
||||||
model.addAttribute("message", "계정이 잠겼습니다. 관리자에게 문의 바랍니다.");
|
model.addAttribute("message", "계정이 잠겼습니다. 관리자에게 문의 바랍니다.");
|
||||||
@ -206,6 +217,10 @@ public class EgovLoginController {
|
|||||||
LOG.error("[개인정보/#22] 로그인 성공 후 실패이력 정리 오류: " + e.getMessage());
|
LOG.error("[개인정보/#22] 로그인 성공 후 실패이력 정리 오류: " + e.getMessage());
|
||||||
}
|
}
|
||||||
request.getSession().setAttribute("loginVO", resultVO);
|
request.getSession().setAttribute("loginVO", resultVO);
|
||||||
|
// [개인정보보호 SER-001 요건5] 로그인 시각 도장 — 관리자 상단 '로그인 후 경과시간' 기준점.
|
||||||
|
// 수동 로그아웃은 세션을 무효화하지 않으므로, 여기서 매 로그인마다 다시 찍지 않으면
|
||||||
|
// 같은 세션으로 재로그인했을 때 직전 로그인 시각이 그대로 쓰여 경과시간이 부풀려진다.
|
||||||
|
SessionPolicyInterceptor.stampLogin(request.getSession(), resultVO.getId());
|
||||||
// [개인정보보호 SFR-003 / #14·#52] 로그인 통합 2단계 인증:
|
// [개인정보보호 SFR-003 / #14·#52] 로그인 통합 2단계 인증:
|
||||||
// 비밀번호 인증 성공 직후 2차 인증 화면으로 유도한다(관리자 actionLogin 경로 전용).
|
// 비밀번호 인증 성공 직후 2차 인증 화면으로 유도한다(관리자 actionLogin 경로 전용).
|
||||||
// secondFactor.do가 등록자→OTP 검증 / 미등록자→등록 유도를 처리하고,
|
// secondFactor.do가 등록자→OTP 검증 / 미등록자→등록 유도를 처리하고,
|
||||||
@ -237,7 +252,10 @@ public class EgovLoginController {
|
|||||||
// 당일 누적 실패 횟수
|
// 당일 누적 실패 횟수
|
||||||
currentFail = loginService.selectFailCount(map);
|
currentFail = loginService.selectFailCount(map);
|
||||||
// 임계 초과 시 계정 잠금(LOCK_YN='Y')
|
// 임계 초과 시 계정 잠금(LOCK_YN='Y')
|
||||||
if (currentFail >= threshold) {
|
// [#24] 잠금 예외 계정은 잠금만 건너뛴다 — 위 insertLoginFail(LOGIN_HISTORY)은
|
||||||
|
// 예외 여부와 무관하게 이미 수행됐다. 잠금을 면제해도 실패 시도 사실·시각·IP는
|
||||||
|
// 감사 추적에 그대로 남아야 한다.
|
||||||
|
if (currentFail >= threshold && !isLockExempt(loginVO)) {
|
||||||
loginService.updateFailLogin(loginVO);
|
loginService.updateFailLogin(loginVO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -257,6 +275,15 @@ public class EgovLoginController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [개인정보/#24] 계정 잠금 예외 여부. LOGIN_LOCK_EXEMPT 는 취급자(COMTNEMPLYRINFO) ID 목록이므로
|
||||||
|
* userSe='USR' 일 때만 조회한다. actionLogin 은 그룹(BIZ_GROUP)·활동처(COMTNENTRPRSMBER)도
|
||||||
|
* 타는 경로라, 구분 없이 물으면 관리자와 같은 ID 문자열을 쓰는 그룹/활동처 계정의 잠금까지 풀린다.
|
||||||
|
*/
|
||||||
|
private boolean isLockExempt(LoginVO loginVO) {
|
||||||
|
return "USR".equals(loginVO.getUserSe()) && loginLockExemptService.isExempt(loginVO.getId());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 일반(세션) 로그인2을 처리한다
|
* 일반(세션) 로그인2을 처리한다
|
||||||
* @param vo - 아이디, 비밀번호가 담긴 LoginVO
|
* @param vo - 아이디, 비밀번호가 담긴 LoginVO
|
||||||
@ -629,6 +656,10 @@ public class EgovLoginController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.getSession().setAttribute("loginVO", null);
|
request.getSession().setAttribute("loginVO", null);
|
||||||
|
// [개인정보보호 SER-001 요건5] 세션은 살려두는 경로라 로그인 시각 도장을 명시적으로 지운다.
|
||||||
|
// 안 지우면 같은 세션으로 재로그인했을 때 직전 로그인 시각이 남아
|
||||||
|
// 로그인 직후 "1시간 N분 경과" 처럼 표시된다.
|
||||||
|
SessionPolicyInterceptor.clearLoginStamp(request.getSession(false));
|
||||||
// return "redirect:/j_spring_security_logout";
|
// return "redirect:/j_spring_security_logout";
|
||||||
return "redirect:/frt/main.do";
|
return "redirect:/frt/main.do";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,4 +150,7 @@
|
|||||||
|
|
||||||
<!-- 개인정보보호(SER-001 요건4·5) - 접근통제 정책(ACCESS_CONTROL_POLICY + LOGIN_LOCK_COUNT 동기화) -->
|
<!-- 개인정보보호(SER-001 요건4·5) - 접근통제 정책(ACCESS_CONTROL_POLICY + LOGIN_LOCK_COUNT 동기화) -->
|
||||||
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml"/>
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
|
<!-- 개인정보보호(SER-001 #24) - 로그인 실패 계정잠금 예외 화이트리스트(LOGIN_LOCK_EXEMPT) -->
|
||||||
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginLockExempt_SQL_Mysql.xml"/>
|
||||||
</sqlMapConfig>
|
</sqlMapConfig>
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SER-001 / 개발항목 24) — 로그인 실패 계정잠금 예외.
|
||||||
|
|
||||||
|
LOGIN_LOCK_EXEMPT 는 잠금 '판정'만 면제하는 화이트리스트다.
|
||||||
|
실패 이력(LOGIN_HISTORY) 적재와 2차 인증은 예외 계정에도 그대로 적용되므로
|
||||||
|
이 파일에는 그 두 경로를 건드리는 statement 가 없다.
|
||||||
|
|
||||||
|
[주의] iBatis 2 는 '#' 을 파라미터 구분자로 쓴다. SQL 주석에 '#' 을 쓰지 말 것.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="LoginLockExemptDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="loginLockExemptVO"
|
||||||
|
type="egovframework.com.cmm.privacy.policy.LoginLockExemptVO"/>
|
||||||
|
|
||||||
|
<!-- ================= 캐시 적재용 ID 전건 조회 ================= -->
|
||||||
|
<select id="LoginLockExemptDAO.selectExemptIds" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT EXEMPT_ID
|
||||||
|
FROM LOGIN_LOCK_EXEMPT
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= 관리자 화면 목록 ================= -->
|
||||||
|
<select id="LoginLockExemptDAO.selectExemptList" resultClass="loginLockExemptVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT e.EXEMPT_ID AS exemptId,
|
||||||
|
e.EXEMPT_RESN AS exemptResn,
|
||||||
|
e.REG_ID AS regId,
|
||||||
|
DATE_FORMAT(e.REG_DT, '%Y-%m-%d %H:%i:%s') AS regDt,
|
||||||
|
m.USER_NM AS userNm
|
||||||
|
FROM LOGIN_LOCK_EXEMPT e
|
||||||
|
LEFT JOIN COMTNEMPLYRINFO m ON m.EMPLYR_ID = e.EXEMPT_ID
|
||||||
|
ORDER BY e.REG_DT DESC, e.EXEMPT_ID
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= 등록 대상 실존 확인(취급자 한정) ================= -->
|
||||||
|
<select id="LoginLockExemptDAO.selectEmplyr" parameterClass="java.lang.String"
|
||||||
|
resultClass="loginLockExemptVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT CAST(EMPLYR_ID AS CHAR) AS exemptId,
|
||||||
|
USER_NM AS userNm
|
||||||
|
FROM COMTNEMPLYRINFO
|
||||||
|
WHERE EMPLYR_ID = #value#
|
||||||
|
LIMIT 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= 등록 / 해제 ================= -->
|
||||||
|
<insert id="LoginLockExemptDAO.insertExempt" parameterClass="loginLockExemptVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO LOGIN_LOCK_EXEMPT (EXEMPT_ID, EXEMPT_RESN, REG_ID, REG_DT)
|
||||||
|
VALUES (#exemptId#, #exemptResn#, #regId#, NOW())
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<delete id="LoginLockExemptDAO.deleteExempt" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
DELETE FROM LOGIN_LOCK_EXEMPT
|
||||||
|
WHERE EXEMPT_ID = #value#
|
||||||
|
]]>
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SER-001 / 개발항목 24) — 로그인 실패 계정잠금 예외 Oracle 쌍(이중관리 규약).
|
||||||
|
※ arko 런타임은 MySQL sqlmap 만 로드. 본 파일은 이중관리 관례상 병행 유지.
|
||||||
|
2026-08-10: 계정잠금 예외 화이트리스트 신규.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="LoginLockExemptDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="loginLockExemptVO"
|
||||||
|
type="egovframework.com.cmm.privacy.policy.LoginLockExemptVO"/>
|
||||||
|
|
||||||
|
<select id="LoginLockExemptDAO.selectExemptIds" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT EXEMPT_ID
|
||||||
|
FROM LOGIN_LOCK_EXEMPT
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="LoginLockExemptDAO.selectExemptList" resultClass="loginLockExemptVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT e.EXEMPT_ID AS exemptId,
|
||||||
|
e.EXEMPT_RESN AS exemptResn,
|
||||||
|
e.REG_ID AS regId,
|
||||||
|
TO_CHAR(e.REG_DT, 'YYYY-MM-DD HH24:MI:SS') AS regDt,
|
||||||
|
m.USER_NM AS userNm
|
||||||
|
FROM LOGIN_LOCK_EXEMPT e
|
||||||
|
LEFT JOIN COMTNEMPLYRINFO m ON m.EMPLYR_ID = e.EXEMPT_ID
|
||||||
|
ORDER BY e.REG_DT DESC, e.EXEMPT_ID
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="LoginLockExemptDAO.selectEmplyr" parameterClass="java.lang.String"
|
||||||
|
resultClass="loginLockExemptVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT EMPLYR_ID AS exemptId,
|
||||||
|
USER_NM AS userNm
|
||||||
|
FROM COMTNEMPLYRINFO
|
||||||
|
WHERE EMPLYR_ID = #value#
|
||||||
|
AND ROWNUM = 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="LoginLockExemptDAO.insertExempt" parameterClass="loginLockExemptVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO LOGIN_LOCK_EXEMPT (EXEMPT_ID, EXEMPT_RESN, REG_ID, REG_DT)
|
||||||
|
VALUES (#exemptId#, #exemptResn#, #regId#, SYSDATE)
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<delete id="LoginLockExemptDAO.deleteExempt" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
DELETE FROM LOGIN_LOCK_EXEMPT
|
||||||
|
WHERE EXEMPT_ID = #value#
|
||||||
|
]]>
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -14,6 +14,7 @@
|
|||||||
· 계정 잠금 정책 : 인증 실패 잠금 횟수(현행 10 -> 5), 잠금 해제 방식(현행 DB 직접 해제)
|
· 계정 잠금 정책 : 인증 실패 잠금 횟수(현행 10 -> 5), 잠금 해제 방식(현행 DB 직접 해제)
|
||||||
· 자동 로그아웃 : 미사용 자동 로그아웃 분(현행 web.xml 60 -> 30), 만료 임박 안내
|
· 자동 로그아웃 : 미사용 자동 로그아웃 분(현행 web.xml 60 -> 30), 만료 임박 안내
|
||||||
· 세션 재발급 : 2차 인증 성공 시 세션 재발급(현행 재발급 없음)
|
· 세션 재발급 : 2차 인증 성공 시 세션 재발급(현행 재발급 없음)
|
||||||
|
· 잠금 예외 계정 : 최고관리자 완전 잠김 방지 화이트리스트(LOGIN_LOCK_EXEMPT, 개발항목 24)
|
||||||
|
|
||||||
저장 시 web.xml 수정·재배포 없이 즉시 반영된다(SessionPolicyInterceptor / LOGIN_LOCK_COUNT 동기화).
|
저장 시 web.xml 수정·재배포 없이 즉시 반영된다(SessionPolicyInterceptor / LOGIN_LOCK_COUNT 동기화).
|
||||||
--%>
|
--%>
|
||||||
@ -102,14 +103,96 @@ function fn_reload() {
|
|||||||
location.href = "<c:url value='${PATH}/sec/accessPolicy/policyView.do' />?page=9100047";
|
location.href = "<c:url value='${PATH}/sec/accessPolicy/policyView.do' />?page=9100047";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 잠금 예외 계정(#24) ────────────────────────────────────────────
|
||||||
|
// 값은 전부 .text() 로 넣는다 — 사유는 관리자 입력 문자열이라 HTML 로 이어붙이면 XSS 통로가 된다.
|
||||||
|
function fn_exemptRow(item) {
|
||||||
|
var tr = $("<tr />");
|
||||||
|
var name = item.userNm ? (" (" + item.userNm + ")") : "";
|
||||||
|
tr.append($("<td />").text(item.exemptId + name));
|
||||||
|
tr.append($("<td />").css("text-align", "left").text(item.exemptResn));
|
||||||
|
tr.append($("<td />").text(item.regId ? item.regId : "-"));
|
||||||
|
tr.append($("<td />").text(item.regDt ? item.regDt : "-"));
|
||||||
|
|
||||||
|
var btn = $("<a />").attr("href", "javascript:void(0);").addClass("btn1").text("해제");
|
||||||
|
btn.click(function() { fn_exemptDelete(item.exemptId); });
|
||||||
|
tr.append($("<td />").append(btn));
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_exemptLoad() {
|
||||||
|
var tbody = $("#exemptBody");
|
||||||
|
tbody.empty().append($("<tr />").append($("<td colspan='5' />").text("불러오는 중...")));
|
||||||
|
|
||||||
|
$.post("<c:url value='${PATH}/privacy/lockExemptList.do' />", {}, function(data) {
|
||||||
|
tbody.empty();
|
||||||
|
if (!data || data.result !== "success") {
|
||||||
|
tbody.append($("<tr />").append($("<td colspan='5' />").text("목록을 불러오지 못했습니다.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var list = data.list || [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
tbody.append($("<tr />").append($("<td colspan='5' />").text("등록된 잠금 예외 계정이 없습니다.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
tbody.append(fn_exemptRow(list[i]));
|
||||||
|
}
|
||||||
|
}, "json").fail(function() {
|
||||||
|
tbody.empty().append($("<tr />").append($("<td colspan='5' />").text("목록 조회 중 오류가 발생하였습니다.")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_exemptAdd() {
|
||||||
|
var id = $.trim($("#exemptId").val());
|
||||||
|
var resn = $.trim($("#exemptResn").val());
|
||||||
|
if (id.length === 0) { alert("예외로 등록할 계정 ID를 입력하세요."); $("#exemptId").focus(); return; }
|
||||||
|
if (resn.length === 0) { alert("예외 등록 사유를 입력하세요.\n사유는 감사 대응상 필수입니다."); $("#exemptResn").focus(); return; }
|
||||||
|
|
||||||
|
if (!confirm("[" + id + "] 계정을 로그인 실패 잠금 대상에서 제외합니다.\n\n"
|
||||||
|
+ "· 실패 이력(LOGIN_HISTORY)은 예외 계정도 그대로 기록됩니다.\n"
|
||||||
|
+ "· 2단계 인증(OTP)은 예외 없이 동일하게 적용됩니다.\n"
|
||||||
|
+ "· 무차별 대입 시도가 있어도 이 계정은 잠기지 않습니다.\n\n계속하시겠습니까?")) { return; }
|
||||||
|
|
||||||
|
$.post("<c:url value='${PATH}/privacy/lockExemptAdd.do' />",
|
||||||
|
{ exemptId: id, exemptResn: resn }, function(data) {
|
||||||
|
if (data && data.result === "success") {
|
||||||
|
alert("등록되었습니다. 다음 로그인 시도부터 적용됩니다.");
|
||||||
|
$("#exemptId").val(""); $("#exemptResn").val("");
|
||||||
|
fn_exemptLoad();
|
||||||
|
} else {
|
||||||
|
alert("등록에 실패하였습니다.\n" + (data && data.message ? data.message : ""));
|
||||||
|
}
|
||||||
|
}, "json").fail(function() {
|
||||||
|
alert("등록 처리 중 오류가 발생하였습니다.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_exemptDelete(id) {
|
||||||
|
if (!confirm("[" + id + "] 계정의 잠금 예외를 해제합니다.\n"
|
||||||
|
+ "해제 즉시 이 계정도 인증 실패 누적 시 잠기게 됩니다.\n\n계속하시겠습니까?")) { return; }
|
||||||
|
|
||||||
|
$.post("<c:url value='${PATH}/privacy/lockExemptDelete.do' />", { exemptId: id }, function(data) {
|
||||||
|
if (data && data.result === "success") {
|
||||||
|
alert("해제되었습니다.");
|
||||||
|
fn_exemptLoad();
|
||||||
|
} else {
|
||||||
|
alert("해제에 실패하였습니다.\n" + (data && data.message ? data.message : ""));
|
||||||
|
}
|
||||||
|
}, "json").fail(function() {
|
||||||
|
alert("해제 처리 중 오류가 발생하였습니다.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
fn_toggleNotice();
|
fn_toggleNotice();
|
||||||
|
fn_exemptLoad();
|
||||||
});
|
});
|
||||||
//-->
|
//-->
|
||||||
</script>
|
</script>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.unit { margin-left:4px; color:#666; }
|
.unit { margin-left:4px; color:#666; }
|
||||||
.sec_head { margin:16px 0 6px 0; padding-left:8px; border-left:3px solid #1f5f9c; font-weight:bold; color:#222; }
|
.sec_head { margin:16px 0 6px 0; padding-left:8px; border-left:3px solid #1f5f9c; font-weight:bold; color:#222; }
|
||||||
|
.exempt_note { margin:0 0 8px 0; padding:8px 10px; background:#f7f7f7; border:1px solid #ddd; color:#444; line-height:1.6; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -141,8 +224,7 @@ $(document).ready(function() {
|
|||||||
<th><label for="unlockMode">잠금 해제 방식</label> *</th>
|
<th><label for="unlockMode">잠금 해제 방식</label> *</th>
|
||||||
<td>
|
<td>
|
||||||
<select name="unlockMode" id="unlockMode" title="잠금 해제 방식">
|
<select name="unlockMode" id="unlockMode" title="잠금 해제 방식">
|
||||||
<option value="ADMIN_HIST" <c:if test="${policy.unlockMode ne 'ADMIN_HIST_RESN'}">selected="selected"</c:if>>관리자 화면 해제 + 해제 이력 적재</option>
|
<option value="ADMIN_HIST" selected="selected">관리자 화면 해제 + 해제 이력 적재</option>
|
||||||
<option value="ADMIN_HIST_RESN" <c:if test="${policy.unlockMode eq 'ADMIN_HIST_RESN'}">selected="selected"</c:if>>관리자 화면 해제 + 해제 사유 필수 + 이력 적재</option>
|
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -216,6 +298,67 @@ $(document).ready(function() {
|
|||||||
<!--// 버튼 -->
|
<!--// 버튼 -->
|
||||||
</form:form>
|
</form:form>
|
||||||
|
|
||||||
|
<div class="sec_head">잠금 예외 계정</div>
|
||||||
|
<p class="exempt_note">
|
||||||
|
여기 등록된 계정은 인증 실패가 누적되어도 <strong>잠기지 않습니다</strong>.
|
||||||
|
잠금 해제 화면은 관리자 로그인이 있어야 들어갈 수 있어, 최고관리자 계정이 잠기면
|
||||||
|
아무도 해제할 수 없는 상태가 되므로 그에 대비한 통로입니다.<br />
|
||||||
|
실패 이력(접속기록)은 예외 계정도 그대로 남고, 2단계 인증(OTP)도 예외 없이 적용됩니다.
|
||||||
|
<strong>잠금 예외는 인증 예외가 아닙니다.</strong> 꼭 필요한 최소 인원만 등록하세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table summary="잠금 예외 계정 목록" class="board_list">
|
||||||
|
<caption>잠금 예외 계정 목록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="20%" />
|
||||||
|
<col width="*" />
|
||||||
|
<col width="14%" />
|
||||||
|
<col width="18%" />
|
||||||
|
<col width="10%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">계정 ID</th>
|
||||||
|
<th scope="col">사유</th>
|
||||||
|
<th scope="col">등록자</th>
|
||||||
|
<th scope="col">등록일시</th>
|
||||||
|
<th scope="col">해제</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="exemptBody">
|
||||||
|
<tr><td colspan="5">불러오는 중...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table summary="잠금 예외 계정 등록" class="write">
|
||||||
|
<caption>잠금 예외 계정 등록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="25%" />
|
||||||
|
<col width="*" />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th><label for="exemptId">계정 ID</label> *</th>
|
||||||
|
<td>
|
||||||
|
<input type="text" id="exemptId" name="exemptId" class="tbox" maxlength="20"
|
||||||
|
style="width:200px;" title="잠금 예외로 등록할 계정 ID" />
|
||||||
|
<span class="unit">개인정보취급자 계정만 등록할 수 있습니다.</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><label for="exemptResn">사유</label> *</th>
|
||||||
|
<td>
|
||||||
|
<input type="text" id="exemptResn" name="exemptResn" class="tbox" maxlength="500"
|
||||||
|
style="width:90%;" title="예외 등록 사유" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="btn_zone">
|
||||||
|
<a href="javascript:fn_exemptAdd();" class="btn2">예외 계정 등록</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!--// 컨텐츠 영역 -->
|
<!--// 컨텐츠 영역 -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 로그인 실패 계정잠금 예외 : LOGIN_LOCK_EXEMPT (화이트리스트)
|
||||||
|
-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001 / 개발항목 24)
|
||||||
|
-- 작성일: 2026-08-10 DB: MySQL 8 / MariaDB
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- [배경] 인증 실패 5회 잠금(LOGIN_LOCK_COUNT)은 userSe 구분 없이 전 계정에 적용된다.
|
||||||
|
-- 잠금 해제 화면(/uss/umt/EgovAccountUnlockManage.do)은 관리자 로그인이 있어야
|
||||||
|
-- 들어갈 수 있는 mnt 메뉴라서, 최고관리자 계정이 잠기면 아무도 해제할 수 없는
|
||||||
|
-- 완전 잠김(lock-out) 상태가 된다. 이 테이블은 그 break-glass 통로다.
|
||||||
|
--
|
||||||
|
-- [적용 범위] 잠금 판정만 면제한다. 아래는 예외 계정에도 그대로 적용된다.
|
||||||
|
-- · 인증 실패 이력(LOGIN_HISTORY) 적재 — 감사 추적은 예외 없이 유지
|
||||||
|
-- · 2차 인증(OTP 등, secondFactor.do) — 잠금 예외 != 인증 예외
|
||||||
|
-- · 비밀번호 검증 자체 — 틀린 비밀번호로 로그인되지 않는다
|
||||||
|
--
|
||||||
|
-- [시드] 초기 데이터 없음. 어떤 계정을 예외로 둘지는 운영팀이 관리자 화면
|
||||||
|
-- (/mnt/sec/accessPolicy/policyView.do '잠금 예외 계정' 섹션)에서 직접 등록한다.
|
||||||
|
-- 특정 ID를 스크립트에 하드코딩하지 않는다.
|
||||||
|
--
|
||||||
|
-- [재실행 안전] CREATE TABLE IF NOT EXISTS — 반복 실행 무해.
|
||||||
|
-- [한글 리터럴] COMMENT 에만 한글 사용. 적용 시 클라이언트 문자셋 utf8mb4 확인.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS LOGIN_LOCK_EXEMPT (
|
||||||
|
EXEMPT_ID VARCHAR(20) NOT NULL COMMENT '잠금 예외 대상 계정ID(COMTNEMPLYRINFO.EMPLYR_ID)',
|
||||||
|
EXEMPT_RESN VARCHAR(500) NOT NULL COMMENT '예외 등록 사유(필수 - 감사 대응)',
|
||||||
|
REG_ID VARCHAR(20) NULL COMMENT '등록한 관리자 계정ID',
|
||||||
|
REG_DT DATETIME NULL COMMENT '등록일시',
|
||||||
|
PRIMARY KEY (EXEMPT_ID)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
COMMENT='로그인 실패 계정잠금 예외 계정(개인정보보호 SER-001 24, 최고관리자 잠김 방지)';
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- [검증]
|
||||||
|
-- SELECT * FROM LOGIN_LOCK_EXEMPT;
|
||||||
|
-- -- 예외 등록 후에도 실패 이력은 남는지 확인:
|
||||||
|
-- SELECT * FROM LOGIN_HISTORY WHERE ID = '등록한ID' ORDER BY 1 DESC LIMIT 10;
|
||||||
|
--
|
||||||
|
-- [운영 메모]
|
||||||
|
-- 예외 등록 전에 이미 LOCK_YN='Y' 가 찍혀 있어도 등록 시점부터 로그인이 가능하다
|
||||||
|
-- (잠금 선확인 자체를 건너뛰므로). LOCK_YN 값 자체를 되돌릴 필요는 없다.
|
||||||
|
--
|
||||||
|
-- [롤백]
|
||||||
|
-- DROP TABLE LOGIN_LOCK_EXEMPT;
|
||||||
|
-- -- 테이블이 없으면 서비스가 fail-closed 로 동작해 전 계정이 정상 잠금 판정된다.
|
||||||
|
-- =====================================================================
|
||||||
Loading…
Reference in New Issue
Block a user