비밀번호 정책 및 변경 강제 적용 기능 추가:

- 비밀번호 규칙 클라이언트 검증 JS/SASS 추가
- 세션 기반 비밀번호 변경 강제 Interceptor 구현 (`PasswordChangeEnforcementInterceptor`)
- URL 리팩토링 및 엔드포인트 변경 (`/password/*`)
- 비밀번호 유효성 검사 공통화 (`PasswordRuleValidator`)
- Logback ERROR 전용 파일 로그 추가
This commit is contained in:
Rinjae
2026-07-27 17:25:42 +09:00
parent 78898ab5a8
commit 41be827b81
38 changed files with 594 additions and 562 deletions
+4 -4
View File
@@ -369,12 +369,12 @@ page:
credential_detail:
name: "인증키 정보"
path: "/myapikey/credential_detail"
change_password:
password_verify:
name: "비밀번호 변경"
path: "/change_password"
verify_current_password:
path: "/password/verify"
password_change:
name: "비밀번호 변경"
path: "/verify_current_password"
path: "/password/change"
myapikey_register_step1:
name: "앱 생성 (기본 정보)"
path: "/myapikey/register/step1"
+20
View File
@@ -69,6 +69,24 @@
</encoder>
</appender>
<!-- ERROR 레벨만 별도 수집(스택 트레이스 포함). 장애 원인 추적용. -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
@@ -89,12 +107,14 @@
<root level="INFO">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<springProfile name="dev">
<root level="DEBUG">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</springProfile>
</configuration>
@@ -39,7 +39,7 @@ deptUserManageRegister.id=Department User ID
deptUserManageRegister.name=Department User Name
portalUser.confirm.password=Please enter your existing password
portalUser.Register.userName=Name
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-20 characters)
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-50 characters)
portalUser.Register.passConfirm=Confirm Password
portalUser.Register.email=Email ID
portalUser.Register.domain=Domain
@@ -39,7 +39,7 @@ deptUserManageRegister.name=\uBD80\uC11C \uC0AC\uC6A9\uC790 \uC774\uB984
entrprsUserManageList.regName=\uBC95\uC778 \uC0AC\uC6A9\uC790 \uB4F1\uB85D \uC774\uB984
portalUser.confirm.password=\uAE30\uC874 \uBE44\uBC00\uBC88\uD638\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694
portalUser.Register.userName=\uC774\uB984
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-20\uC790
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-50\uC790
portalUser.Register.passConfirm=\uBE44\uBC00\uBC88\uD638 \uD655\uC778
portalUser.Register.email=\uC774\uBA54\uC77C \uC544\uC774\uB514
portalUser.Register.domain=\uB3C4\uBA54\uC778
+63
View File
@@ -7571,6 +7571,69 @@ button.djb-comment-submit:disabled {
font-size: 13px;
}
.password-policy-checklist {
list-style: none;
padding: 0;
margin: 12px 0 0 0;
}
.password-policy-checklist li {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
line-height: 20px;
margin-bottom: 6px;
transition: color 0.15s ease;
}
.password-policy-checklist li:last-child {
margin-bottom: 0;
}
.password-policy-checklist li .policy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.password-policy-checklist li .policy-icon::before {
content: "•";
}
.password-policy-checklist li.is-idle {
color: #888;
}
.password-policy-checklist li.is-idle .policy-icon {
color: #b5b5b5;
}
.password-policy-checklist li.is-pass {
color: #1a8f4c;
}
.password-policy-checklist li.is-pass .policy-icon {
color: #1a8f4c;
}
.password-policy-checklist li.is-pass .policy-icon::before {
content: "✔";
}
.password-policy-checklist li.is-fail {
color: #d63a3a;
}
.password-policy-checklist li.is-fail .policy-icon {
color: #d63a3a;
}
.password-policy-checklist li.is-fail .policy-icon::before {
content: "✖";
}
.password-policy-note {
margin: 10px 0 0 0;
font-size: 14px;
line-height: 18px;
color: #888;
}
.hero-carousel-section {
position: relative;
width: 100%;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
/**
* 비밀번호 문자열 정책 라이브 검증 (공용)
*
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
*
* 사용법(마크업 구동):
* <ul class="password-policy-checklist" data-password-input="newPassword">
* <li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">...</span></li>
* ... (rule: length | letter | digit | special | nospace | norepeat | noseq)
* </ul>
* <input type="password" id="newPassword" ...>
*
* DOM ready 시 자동으로 스캔하여 대상 input 에 바인딩한다.
*/
(function (global) {
'use strict';
// 3자리 연속(오름/내림) 문자·숫자 검사 — 서버 로직과 동일하게 대문자로 비교
function hasSequential(pw) {
var s = pw.toUpperCase();
for (var i = 0; i < s.length - 2; i++) {
var a = s.charCodeAt(i), b = s.charCodeAt(i + 1), c = s.charCodeAt(i + 2);
var sameCategory = (a > 47 && c < 58) || (a > 64 && c < 91); // 숫자 0-9 또는 영문 A-Z
var consecutive = Math.abs(c - b) === 1 && Math.abs(c - a) === 2;
if (sameCategory && consecutive) {
return true;
}
}
return false;
}
// 규칙별 판정 함수 (통과=true)
var RULES = {
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
digit: function (pw) { return /[0-9]/.test(pw); },
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
nospace: function (pw) { return !/\s/.test(pw); },
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
noseq: function (pw) { return !hasSequential(pw); }
};
// 전체 문자열 규칙 통과 여부
function isValid(pw) {
if (!pw) {
return false;
}
for (var key in RULES) {
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
return false;
}
}
return true;
}
// 체크리스트(ul) 하나를 대상 input 에 바인딩
function bind(input, list) {
var $input = (input && input.jquery) ? input : $(input);
var $list = (list && list.jquery) ? list : $(list);
var $items = $list.find('li[data-rule]');
if (!$input.length || !$items.length) {
return;
}
function update() {
var pw = $input.val() || '';
$items.each(function () {
var $li = $(this);
var rule = RULES[$li.attr('data-rule')];
if (!rule) {
return;
}
$li.removeClass('is-idle is-pass is-fail');
if (pw.length === 0) {
$li.addClass('is-idle');
} else {
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
}
});
}
$input.on('input.passwordPolicy', update);
update();
}
// 마크업 구동 자동 초기화
function init(root) {
var $root = root ? $(root) : $(document);
$root.find('ul.password-policy-checklist[data-password-input]').each(function () {
var $list = $(this);
var input = document.getElementById($list.attr('data-password-input'));
if (input) {
bind(input, $list);
}
});
}
global.PasswordPolicy = {
RULES: RULES,
isValid: isValid,
bind: bind,
init: init
};
$(function () {
init(document);
});
})(window);
@@ -0,0 +1,75 @@
// 비밀번호 문자열 정책 라이브 체크리스트 (마이페이지 비밀번호 변경 + 회원가입 공용)
.password-policy-checklist {
list-style: none;
padding: 0;
margin: 12px 0 0 0;
li {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
line-height: 20px;
margin-bottom: 6px;
transition: color 0.15s ease;
&:last-child {
margin-bottom: 0;
}
.policy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
line-height: 1;
&::before {
content: "\2022"; // •
}
}
&.is-idle {
color: #888;
.policy-icon {
color: #b5b5b5;
}
}
&.is-pass {
color: #1a8f4c;
.policy-icon {
color: #1a8f4c;
&::before {
content: "\2714"; // ✔
}
}
}
&.is-fail {
color: #d63a3a;
.policy-icon {
color: #d63a3a;
&::before {
content: "\2716"; // ✖
}
}
}
}
}
.password-policy-note {
margin: 10px 0 0 0;
font-size: 14px;
line-height: 18px;
color: #888;
}
+1
View File
@@ -44,6 +44,7 @@
@use 'components/breadcrumb' as *;
@use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *;
@use 'components/password-policy' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -15,7 +15,7 @@
<a class="nav-link" th:href="@{/my_company_info}">기업 정보 변경</a>
</li>
<li class="nav-item">
<a class="nav-link active" th:href="@{/change_password}">비밀번호 변경</a>
<a class="nav-link active" th:href="@{/password/verify}">비밀번호 변경</a>
</li>
</ul>
<div class="card mt-2">
@@ -19,7 +19,7 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form id="passwordChangeForm" th:action="@{/mypage/change_new_password}" method="post">
<form id="passwordChangeForm" th:action="@{/password/change}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form-container">
@@ -37,7 +37,7 @@
<span class="form-label-text">새 비밀번호</span>
</div>
<div class="form-field-wrapper">
<input type="password" name="newPassword" class="form-input"
<input type="password" id="newPassword" name="newPassword" class="form-input"
placeholder="새 비밀번호 입력" required>
</div>
</div>
@@ -52,10 +52,16 @@
</div>
</div>
<ul class="password-rules-list">
<li>영문 대문자, 소문자, 숫자, 특수문자 8~20자</li>
<li>아이디, 휴대전화, 3자리 이상연속, 반복문자 사용불가</li>
<ul class="password-policy-checklist" data-password-input="newPassword">
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자 포함 8~50자</span></li>
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문 포함</span></li>
<li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span class="policy-text">숫자 포함</span></li>
<li data-rule="special" class="is-idle"><span class="policy-icon"></span><span class="policy-text">특수문자 포함</span></li>
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span class="policy-text">공백 사용 불가</span></li>
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
</ul>
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div>
<div class="form-actions" style="justify-content: flex-end;">
@@ -16,7 +16,7 @@
<div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/verify_current_password}" method="post">
<form th:action="@{/password/verify}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form-container">
@@ -42,7 +42,7 @@
<div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
<button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button>
</div>
</div>
@@ -55,6 +55,26 @@
customPopups.showAlert([[${error}]]);
})
</script>
<script th:if="${forcedPasswordReset}" th:inline="javascript">
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
$(function () {
$('#customConfirmCloseButton').hide();
$('#customConfirmYesButton').text('변경');
$('#customConfirmNoButton').text('로그아웃');
customPopups.showConfirm(
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
function (selection) {
if (selection) {
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
$('#customConfirmCloseButton').show();
} else {
// 로그아웃
window.location.href = [[@{/actionLogout.do}]];
}
}
);
});
</script>
</section>
</body>
</html>
@@ -78,6 +78,16 @@
th:placeholder="#{portalUser.Register.pass}">
<input type="hidden" name="isPasswordValid" id="isPasswordValid"/>
<div id="password-validation" class="org-validation-message"></div>
<ul class="password-policy-checklist" data-password-input="password">
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자 포함 8~50자</span></li>
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문 포함</span></li>
<li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span class="policy-text">숫자 포함</span></li>
<li data-rule="special" class="is-idle"><span class="policy-icon"></span><span class="policy-text">특수문자 포함</span></li>
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span class="policy-text">공백 사용 불가</span></li>
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
</ul>
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div>
</div>
@@ -107,6 +107,7 @@
<script th:src="@{/plugins/jquery/jquery-3.7.1.min.js}"></script>
<script th:src="@{/js/lodash.js}"></script>
<script th:src="@{/js/common.js}"></script>
<script th:src="@{/js/password-policy.js}"></script>
<script th:src="@{/js/moment.min.js}"></script>
<script th:src="@{/js/daterangepicker.js}"></script>
<script th:src="@{/plugins/codemirror/codemirror.js}"></script>
@@ -97,7 +97,7 @@
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
<li><a th:href="@{/password/verify}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
</ul>
</div>
</div>
@@ -253,7 +253,7 @@
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
<li><a th:href="@{/password/verify}">비밀번호 변경</a></li>
</ul>
</li>
</ul>
@@ -73,7 +73,7 @@
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a>
<a th:href="@{/change_password}"
<a th:href="@{/password/verify}"
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
class="service-nav__item">비밀번호 변경</a>
</th:block>