25 Commits

Author SHA1 Message Date
Rinjae 37be9a361a 로고 및 스타일 변경 반영:
- 로고 사이즈 수정 (114px → 144px) 및 레이아웃 조정
- 컬러 및 폰트 스타일 Figma 가이드 기준 업데이트
- 버튼/배경 스타일 효과 개선 (글래스, 알약 태그 등)
2026-07-01 09:54:50 +09:00
Rinjae a91b56ed11 - 법인 탈퇴 시 민감정보 삭제 로직 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSS 수정: 폰트 크기 제거 및 스타일 변경 적용
- 예상완료일 요일 계산 로직 추가
2026-06-30 15:38:40 +09:00
Rinjae 8d232734b1 IP 등록 UI 및 검증 로직 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `.ip-input-row`/`.btn-add-ip` 정렬 및 버튼 크기 수정
- Callback URL 관련 입력 및 검증 로직 주석 처리
- 화이트리스트 필수 입력 항목 검증 로직 추가
2026-06-30 10:54:22 +09:00
Rinjae 06c18e437b 회원가입 제출 필드 개선: 선택 입력 포함 모든 항목 전송 처리 2026-06-30 10:54:08 +09:00
Rinjae 97b9d2161a IP 입력 필드 스타일 개선: 정렬 및 버튼 크기 수정
- `.ip-input-row` 정렬 기준 추가 및 버튼 폭 고정
- `.btn-add-ip` 및 제거 버튼 스타일/반응형 조정
2026-06-30 10:54:00 +09:00
Rinjae 0c3b63a5b1 - UserSessionService 목 객체 추가 및 관련 의존성 주입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 시 휴대폰 번호 형식 통일 (`01012345678` → `010-1234-5678`)
- AccountControllerTest 테스트 코드 수정 및 누락 보완
2026-06-30 09:45:08 +09:00
Rinjae 582a3e0f69 회원가입 필수 항목 검증 강화:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 입력/미충족 항목 구체적 수집 및 에러 메시지 개선
- 시나리오별/공통 항목 검증 로직 추가
2026-06-30 09:42:34 +09:00
Rinjae c1ae12e1ca 전화번호 정규화 유틸 추가 및 적용:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PhoneNumberUtil 도입: 한국번호 정규화/숫자 추출 지원
- 유틸 적용: 모든 저장 및 조회에 정규형 강제
- 초대 매칭 오류 수정: 휴대폰 형식 차이 대응
2026-06-29 18:26:15 +09:00
Rinjae 77aea79363 콘솔 로그 설정 추가 및 레벨 제어 기능 구현
- JVM 파라미터 기반 CONSOLE_LOG_ENABLED, LOG_LEVEL 제어 추가
- Stage 프로파일 정리 및 콘솔 로그 필터 설정 적용
2026-06-29 18:24:37 +09:00
Rinjae aa5e769148 JTA 설정 비활성화 및 신규 회원가입 경로 추가
- `spring.jta.enabled` 값 false로 변경 (stage/dev/prod 공통)
- 법인회원 초대 경로(`/signup/decision_process`) 신규 추가
2026-06-29 18:24:26 +09:00
Rinjae ee8e9e3529 CycloneDX BOM 플러그인 추가 - build.gradle에 org.cyclonedx.bom 적용 2026-06-29 18:24:07 +09:00
Rinjae 82948a13ce CSRF 만료 세션 처리 및 정적 리소스 캐싱 제어 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 만료 시 기존 세션 만료 안내로 리다이렉트 처리
- 정적 리소스 캐싱 토글 기능 추가 (개발 환경 즉시 반영 지원)
- 회원가입 페이지 및 세션 설정 스타일 수정
2026-06-26 15:05:19 +09:00
Rinjae f79e528fec 이메일 인증 재발송 기능 제거 및 관련 코드 정리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- `emailResendPopup` 및 팝업 호출 로직 삭제
- `InquiryAdminNotifier` → `CommunityAdminNotifier`로 클래스 명칭 변경
- 제휴 신청 시 관리자 알림 로직 추가
2026-06-24 19:45:37 +09:00
Rinjae 6f776ed72a 로그아웃 핸들러 추가: 세션 무효화 전 DB 정리 및 로깅 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-24 16:52:09 +09:00
Rinjae e242d3ed06 - 모바일 약관 탭 UI 개선 - 스크롤 추가 및 스타일 조정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 필수 입력 경고 메세지 추가 및 인증 미완료 경고 추가
2026-06-24 15:32:18 +09:00
Rinjae d7abe2c2e5 - 테스트 데이터에 휴대폰 번호 추가 및 관련 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 초대 조회 조건: 이메일 → 휴대폰 번호로 변경
2026-06-23 21:06:28 +09:00
Rinjae 84d92fced9 - 초대 로직 변경: 이메일→휴대폰 기반 초대 전환 및 UI 업데이트
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 알림 수신 동의 추가: 초대/취소 시 알림 발송 옵션 탑재
- 관리자 기능 추가: 소속 제외 및 권한 조정 기능 구현
2026-06-23 20:58:27 +09:00
Rinjae 53468c3b83 WAR 파일 해시 파일 확장자 변경 및 MD5SUM 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-06-23 13:58:02 +09:00
Rinjae 198f6fc82a - 회원가입 이메일 인증 페이지 추가 및 이메일 인증 흐름 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 휴대폰 번호 중복 검증 로직 추가 및 프로퍼티 기반 설정 활성화
- 알림 수신 동의 항목 및 뷰 로직 추가
2026-06-22 19:20:03 +09:00
Rinjae 21c500610c Damo 적용 준비 코드
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- KjbNotRnnoJpaAttributeConverter 삭제 및 PersonalDataEncryptConverter 추가
- damo-manager 지원을 위한 JSP 추가 및 레거시 암호화 마이그레이션 컨트롤러 구현
- 기존 엔티티 암호화 로직 변경 및 build.gradle에 damo-manager 의존성 추가
2026-06-22 16:29:45 +09:00
Rinjae 687d4798d4 - error 페이지 개발 환경용 상세 오류 정보 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 스택 트레이스 및 예외 클래스 노출 로직 구현
- activeProfile 표시 및 스타일 업데이트
2026-06-22 15:39:59 +09:00
Rinjae 5adfd42751 - CSRF 토큰 저장소 쿠키 → 세션 전환 및 클라이언트 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- error 페이지 텍스트 동적 처리 추가 (prod 환경 구분)
- CSRF 메타 데이터 추가 및 JS/AJAX CSRF 로직 일원화
2026-06-22 14:19:32 +09:00
Rinjae 6694715195 Merge branch 'feature/session-timeout'
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
# Conflicts:
#	src/main/resources/static/css/main.css.map
2026-06-19 18:09:47 +09:00
Rinjae fc96c13df2 - 로그인 중복 세션 확인 및 기존 세션 강제 로그아웃 처리 추가
- 세션 타이머 데스크톱·모바일 표시 및 경고 스타일 동기화
2026-06-19 18:06:40 +09:00
Rinjae 7265ad6589 - 세션 타이머·유휴 자동 로그아웃 기능 추가
- 만료 직전 연장 확인 모달, PortalProperty로 시간 설정
- PTL_USER_SESSION 기반 세션 검증 필터/API 도입
- 로그인 시 중복 세션 강제 로그아웃 처리
- 세션 테이블 DDL 및 로그인 안내 메시지 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:45:37 +09:00
100 changed files with 3748 additions and 1052 deletions
+6 -3
View File
@@ -92,10 +92,13 @@ pipeline {
sh '''
set -eu
cd build/libs
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
for f in eapim-portal.war eapim-portal-boot.war; do
sha1sum "$f" > "$f.sha1"
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
}
}
}
+6 -3
View File
@@ -91,10 +91,13 @@ pipeline {
sh '''
set -eu
cd build/libs
sha1sum eapim-portal.war eapim-portal-boot.war > SHA1SUMS
sha256sum eapim-portal.war eapim-portal-boot.war > SHA256SUMS
for f in eapim-portal.war eapim-portal-boot.war; do
sha1sum "$f" > "$f.sha1"
sha256sum "$f" > "$f.sha256"
md5sum "$f" > "$f.md5"
done
'''
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/SHA1SUMS,build/libs/SHA256SUMS', fingerprint: true
archiveArtifacts artifacts: 'build/libs/eapim-portal.war,build/libs/eapim-portal-boot.war,build/libs/eapim-portal.war.sha1,build/libs/eapim-portal.war.sha256,build/libs/eapim-portal.war.md5,build/libs/eapim-portal-boot.war.sha1,build/libs/eapim-portal-boot.war.sha256,build/libs/eapim-portal-boot.war.md5', fingerprint: true
}
}
}
+2
View File
@@ -3,6 +3,7 @@ plugins {
id 'war'
id 'eclipse'
id 'idea'
id 'org.cyclonedx.bom' version '3.2.4'
id 'org.springframework.boot' version '2.7.18'
id 'io.spring.dependency-management' version '1.1.3'
}
@@ -170,6 +171,7 @@ test {
bootWar {
archiveFileName = "eapim-portal-boot.war"
mainClass = 'com.eactive.apim.portal.PortalApplication'
}
war {
+47
View File
@@ -8,6 +8,9 @@
"name": "eapim-portal",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.61.1"
},
"devDependencies": {
"sass": "^1.69.5"
}
@@ -349,6 +352,20 @@
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -403,6 +420,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+4 -1
View File
@@ -13,5 +13,8 @@
"sass": "^1.69.5"
},
"author": "",
"license": "ISC"
"license": "ISC",
"dependencies": {
"playwright": "^1.61.1"
}
}
@@ -0,0 +1,36 @@
-- =============================================================================
-- 세션 타이머 / 유휴 자동 로그아웃 / 중복로그인 처리용 사용자 세션 추적 테이블
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.apps.session.entity.UserSession
-- =============================================================================
CREATE TABLE PTL_USER_SESSION
(
SESSION_ID VARCHAR2(128) NOT NULL,
USER_ID VARCHAR2(36) NOT NULL,
LOGIN_ID VARCHAR2(200) NOT NULL,
LOGIN_TIME TIMESTAMP(6) NOT NULL,
LAST_ACCESS_TIME TIMESTAMP(6) NOT NULL,
IP_ADDRESS VARCHAR2(45),
USER_AGENT VARCHAR2(500),
FORCE_LOGOUT CHAR(1) DEFAULT 'N' NOT NULL,
CONSTRAINT PK_PTL_USER_SESSION PRIMARY KEY (SESSION_ID)
);
-- 중복로그인 강제 로그아웃 / 활성 세션 조회는 LOGIN_ID 기준
CREATE INDEX IX_PTL_USER_SESSION_LOGIN ON PTL_USER_SESSION (LOGIN_ID);
COMMENT ON TABLE PTL_USER_SESSION IS '포털 사용자 세션 추적 (타이머/유휴 로그아웃/중복로그인)';
COMMENT ON COLUMN PTL_USER_SESSION.SESSION_ID IS 'HTTP 세션 ID (PK)';
COMMENT ON COLUMN PTL_USER_SESSION.USER_ID IS '포털 사용자 ID';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_ID IS '로그인 ID (이메일, 소문자)';
COMMENT ON COLUMN PTL_USER_SESSION.LOGIN_TIME IS '로그인 시각';
COMMENT ON COLUMN PTL_USER_SESSION.LAST_ACCESS_TIME IS '마지막 접근 시각 (만료 판단 기준)';
COMMENT ON COLUMN PTL_USER_SESSION.IP_ADDRESS IS '접속 IP';
COMMENT ON COLUMN PTL_USER_SESSION.USER_AGENT IS 'User-Agent';
COMMENT ON COLUMN PTL_USER_SESSION.FORCE_LOGOUT IS '강제 로그아웃 플래그 (Y/N)';
-- (선택) 세션 타임아웃(분) 설정값을 미리 지정. 미삽입 시 최초 접근 때 기본값 15로 자동 생성됨.
-- INSERT INTO PTL_PROPERTY (PROPERTY_GROUP_NAME, PROPERTY_NAME, PROPERTY_VALUE, PROPERTY_DESC)
-- VALUES ('Portal', 'session.timeout.minutes', '15', '세션 타임아웃 시간 (분)');
-- COMMIT;
@@ -15,7 +15,11 @@ import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping("/agreements")
public class AgreementsController {
public static final String TERMS_AGREEMENTS = "fragment/kjbank/terms_agreements";
public static final String TERMS_AGREEMENTS = "fragment/djbank/terms_agreements";
/** 개인정보처리방침은 약관 페이지에서 분리되어 제주은행 공식 사이트 외부 링크로 대체됨 */
public static final String PRIVACY_POLICY_EXTERNAL_URL =
"https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do";
private final AgreementsFacade agreementsFacade;
@@ -28,28 +32,49 @@ public class AgreementsController {
public String showTerms(@RequestParam(required = false) String tab,
@RequestParam(required = false) String publishedOn,
Model model) {
String currentTab = tab != null ? tab : "terms";
// 구 개인정보처리방침 탭(tab=privacy)은 외부 링크로 이동했으므로 외부 URL로 리다이렉트(북마크 호환)
if ("privacy".equals(currentTab)) {
return "redirect:" + PRIVACY_POLICY_EXTERNAL_URL;
}
AgreementType type;
if ("privacy".equals(tab)) {
type = AgreementType.PRIVACY_POLICY;
} else {
type = AgreementType.TERMS_OF_USE;
switch (currentTab) {
case "privacy-collect":
// 개인정보수집동의서 = PRIVACY_COLLECT (신설항목)
type = AgreementType.PRIVACY_COLLECT;
break;
case "notification":
type = AgreementType.NOTIFICATION_CONSENT;
break;
case "terms":
default:
currentTab = "terms";
type = AgreementType.TERMS_OF_USE;
break;
}
List<AgreementsDTO> agreementsList = agreementsFacade.getAgreementsList(String.valueOf(type));
model.addAttribute("agreementsList", agreementsList);
AgreementsDTO selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
// 해당 약관 종류가 아직 시드되지 않았을 수 있으므로 빈 리스트 방어
AgreementsDTO selectedAgreement = null;
if (agreementsList != null && !agreementsList.isEmpty()) {
selectedAgreement = publishedOn != null && !publishedOn.isEmpty() ?
findAgreementByDate(agreementsList, publishedOn) : agreementsList.get(0);
}
model.addAttribute("selectedAgreement", selectedAgreement);
model.addAttribute("selectedDate", publishedOn);
model.addAttribute("isTermsOfUse", type == AgreementType.TERMS_OF_USE);
model.addAttribute("isPrivacyPolicy", type == AgreementType.PRIVACY_POLICY);
model.addAttribute("isPrivacyCollect", type == AgreementType.PRIVACY_COLLECT);
model.addAttribute("isNotification", type == AgreementType.NOTIFICATION_CONSENT);
model.addAttribute("agreementTitle", type.getDescription());
model.addAttribute("agreementType", type.getCode());
model.addAttribute("currentTab", tab != null ? tab : "terms");
model.addAttribute("currentTab", currentTab);
return TERMS_AGREEMENTS;
}
@@ -71,8 +71,9 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
throw new NotFoundException("현재 시행중인 이용약관이 없습니다.");
}
// 2. 개인정보수집동의서 처리
if (privacyCollectType != null && privacyCollectType.isPrivacyCollect()) {
// 2. 개인정보수집동의서 처리 (개인정보수집동의서 = PRIVACY_COLLECT 신설항목 포함)
if (privacyCollectType != null
&& (privacyCollectType.isPrivacyCollect() || privacyCollectType == AgreementType.PRIVACY_COLLECT)) {
Optional<Agreements> privacyCollect = agreementsService
.findLastesBeforeDate(
privacyCollectType,
@@ -199,6 +200,10 @@ public class AgreementsFacadeImpl implements AgreementsFacade {
defaultAgreement.setName("개인정보처리방침");
defaultAgreement.setContents("현재 개인정보처리방침을 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT":
defaultAgreement.setName("개인정보수집동의서");
defaultAgreement.setContents("현재 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
break;
case "PRIVACY_COLLECT_IND":
defaultAgreement.setName("개인용 개인정보수집동의서");
defaultAgreement.setContents("현재 개인용 개인정보수집동의서를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.");
@@ -31,4 +31,21 @@ public class ApprovalDTO {
private LocalDateTime createdDate;
private LocalDateTime approvalDate;
private String expectEndDate; //예상완료일 (yyyyMMdd)
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
public String getExpectEndDateDayOfWeek() {
if (expectEndDate == null || expectEndDate.length() < 8) {
return "";
}
try {
return java.time.LocalDate
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
.getDayOfWeek()
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
} catch (Exception e) {
return "";
}
}
}
@@ -3,31 +3,72 @@ package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.LocalDateTime;
import java.util.Optional;
@Component
public class AuthNumberStorage {
private static final String HASH_ALGORITHM = "HmacSHA256";
private final TwoFactorAuthRepository twoFactorAuthRepository;
/**
* recipient(이메일/휴대폰)는 암호화 컬럼이라 값으로 동등 조회/중복정리가 불가능하다.
* 그래서 recipient 평문을 결정적 해시(HMAC-SHA256)로 변환해 별도 조회 키(recipientKey)로 사용한다.
* 평문을 그대로 노출하지 않도록 pepper 로 기존 암호화 키를 재사용한다.
*/
@Value("${encryption.key:kjbank_portal_application_1357902}")
private String hashPepper;
@Autowired
public AuthNumberStorage(TwoFactorAuthRepository twoFactorAuthRepository) {
this.twoFactorAuthRepository = twoFactorAuthRepository;
}
public void saveAuthNumber(String recipientKey, String authNumber, LocalDateTime expiresAt) {
twoFactorAuthRepository.deleteAllByRecipient(recipientKey);
String lookupKey = hashRecipient(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(lookupKey);
TwoFactorAuth auth = new TwoFactorAuth(recipientKey, authNumber, expiresAt);
auth.setRecipientKey(lookupKey);
twoFactorAuthRepository.save(auth);
}
public Optional<TwoFactorAuth> getAuthNumber(String recipientKey) {
return twoFactorAuthRepository.findByRecipient(recipientKey);
return twoFactorAuthRepository.findByRecipientKey(hashRecipient(recipientKey));
}
public void deleteAuthNumber(String recipientKey) {
twoFactorAuthRepository.deleteById(recipientKey);
twoFactorAuthRepository.deleteAllByRecipientKey(hashRecipient(recipientKey));
}
}
/**
* recipient 평문을 결정적 해시(HMAC-SHA256, 소문자 hex)로 변환한다.
* 동일 입력 → 동일 키이므로 저장/조회/정리에서 동등 매칭이 가능하다.
*/
private String hashRecipient(String recipient) {
if (recipient == null) {
return null;
}
try {
Mac mac = Mac.getInstance(HASH_ALGORITHM);
mac.init(new SecretKeySpec(hashPepper.getBytes(StandardCharsets.UTF_8), HASH_ALGORITHM));
byte[] digest = mac.doFinal(recipient.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("2FA recipient 해시 생성 실패", e);
}
}
}
@@ -5,14 +5,18 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.template.entity.MessageCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -21,6 +25,8 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationService partnershipApplicationService;
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
private final CommunityAdminNotifier portalAdminNotifier;
@Override
@@ -43,5 +49,15 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
partnershipApplication.setFileId(file.getFileId());
}
partnershipApplicationService.createPartnershipApplication(partnershipApplication);
// 개선요청/제휴 게시물 등록 시 portal-admin 에게 알림 (INQUIRY_CREATED 와 동일 패턴)
PortalAuthenticatedUser writer = SecurityUtil.getPortalAuthenticatedUser();
Map<String, Object> params = new HashMap<>();
params.put("partnershipId", partnershipApplication.getId());
params.put("bizSubject", partnershipApplication.getBizSubject());
if (writer != null) {
params.put("writerName", writer.getUserName());
}
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
}
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.community.qna.comment.service.InquiryAdminNotifier;
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.template.entity.MessageCode;
@@ -26,7 +26,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
private final InquiryAdminNotifier inquiryAdminNotifier;
private final CommunityAdminNotifier inquiryAdminNotifier;
@Override
public Page<InquiryDTO> getInquiries(InquirySearch search, Pageable pageable, PortalAuthenticatedUser user) {
@@ -152,25 +152,6 @@ public class AccountRecoveryController {
}
}
@PostMapping("/resend_verification_email")
public ResponseEntity<Map<String, String>> resendVerificationEmail(@RequestParam String loginId) {
Map<String, String> response = new HashMap<>();
try {
portalUserAuthService.resendVerificationEmail(loginId);
response.put("success", "인증 이메일이 재발송되었습니다.");
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
response.put("error", e.getMessage());
return ResponseEntity.badRequest().body(response);
} catch (Exception e) {
response.put("error", "이메일 발송 중 오류가 발생했습니다.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
@GetMapping("/dormant_account")
public String showDormantAccountPage(HttpSession session,Model model) {
@@ -0,0 +1,130 @@
package com.eactive.apim.portal.apps.session.controller;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/session")
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final UserSessionService userSessionService;
/**
* 로그인 전 중복 세션 확인
*/
@PostMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
Map<String, Object> result = new HashMap<>();
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
if (activeSession.isPresent()) {
UserSession session = activeSession.get();
result.put("duplicateSession", true);
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
} else {
result.put("duplicateSession", false);
}
return ResponseEntity.ok(result);
}
/**
* 세션 상태 폴링 (인증 필요)
*/
@GetMapping("/status")
public ResponseEntity<Map<String, Object>> getSessionStatus(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("valid", false);
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
boolean forceLoggedOut = userSessionService.isForceLoggedOut(sessionId);
if (forceLoggedOut) {
result.put("valid", false);
result.put("remainingSeconds", 0);
} else {
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("valid", remaining > 0);
result.put("remainingSeconds", remaining);
}
return ResponseEntity.ok(result);
}
/**
* 하트비트 - lastAccessTime 갱신 (세션 연장)
*/
@PostMapping("/heartbeat")
public ResponseEntity<Map<String, Object>> heartbeat(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>();
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
result.put("remainingSeconds", 0);
return ResponseEntity.ok(result);
}
String sessionId = httpSession.getId();
userSessionService.updateLastAccessTime(sessionId);
long remaining = userSessionService.getRemainingSeconds(sessionId);
result.put("remainingSeconds", remaining);
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
*/
private String maskIpAddress(String ip) {
if (ip == null || ip.isEmpty()) {
return "알 수 없음";
}
String[] parts = ip.split("\\.");
if (parts.length == 4) {
return parts[0] + "." + parts[1] + ".***." + parts[3];
}
// IPv6 등 다른 형식은 일부만 표시
if (ip.length() > 8) {
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
}
return "***";
}
}
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.session.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 사용자 세션 추적 엔티티 (EMS 스키마, PTL_USER_SESSION)
*
* <p>화면 세션 타이머/유휴 자동 로그아웃 및 중복로그인 강제 로그아웃에 사용된다.
* {@code lastAccessTime} + 타임아웃(분) 이 세션 만료의 기준이며, {@code forceLogout='Y'} 인 세션은
* 다음 요청 시 {@link com.eactive.apim.portal.apps.session.filter.SessionValidationFilter} 가 강제 로그아웃한다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "PTL_USER_SESSION")
public class UserSession {
@Id
@Column(name = "SESSION_ID", length = 128, nullable = false)
private String sessionId;
@Column(name = "USER_ID", length = 36, nullable = false)
private String userId;
@Column(name = "LOGIN_ID", length = 200, nullable = false)
private String loginId;
@Column(name = "LOGIN_TIME", nullable = false)
private LocalDateTime loginTime;
@Column(name = "LAST_ACCESS_TIME", nullable = false)
private LocalDateTime lastAccessTime;
@Column(name = "IP_ADDRESS", length = 45)
private String ipAddress;
@Column(name = "USER_AGENT", length = 500)
private String userAgent;
@Column(name = "FORCE_LOGOUT", length = 1, nullable = false)
@Builder.Default
private String forceLogout = "N";
}
@@ -0,0 +1,124 @@
package com.eactive.apim.portal.apps.session.filter;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* 인증 사용자 요청마다 DB 세션 상태를 검증한다.
*
* <ol>
* <li>강제 로그아웃(forceLogout='Y') 감지 시 세션 무효화 후 {@code /login?forceLogout=true}</li>
* <li>잔여 시간 만료 시 세션 무효화 후 {@code /login?expired=true}</li>
* <li>그 외에는 60초 스로틀로 lastAccessTime 갱신 (실제 활동 반영)</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionValidationFilter extends OncePerRequestFilter {
private static final String LAST_DB_UPDATE_ATTR = "SESSION_LAST_DB_UPDATE";
private static final long DB_UPDATE_THROTTLE_MS = 60_000; // 60초 스로틀링
private static final List<String> EXCLUDED_PREFIXES = Arrays.asList(
"/api/session/",
"/login",
"/actionLogin.do",
"/actionLogout.do",
"/css/",
"/js/",
"/img/",
"/plugins/",
"/fonts/",
"/favicon.ico",
"/error",
"/health"
);
private final UserSessionService userSessionService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty()) {
path = path.substring(contextPath.length());
}
// 제외 경로 체크
if (isExcludedPath(path)) {
filterChain.doFilter(request, response);
return;
}
// 인증되지 않은 요청은 통과
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
filterChain.doFilter(request, response);
return;
}
HttpSession httpSession = request.getSession(false);
if (httpSession == null) {
filterChain.doFilter(request, response);
return;
}
String sessionId = httpSession.getId();
// 1. 강제 로그아웃 체크 (중복 로그인)
if (userSessionService.isForceLoggedOut(sessionId)) {
log.info("강제 로그아웃 감지 - sessionId: {}", sessionId);
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?forceLogout=true");
return;
}
// 2. 세션 만료 체크 (DB 레코드 없거나 잔여 0)
long remaining = userSessionService.getRemainingSeconds(sessionId);
if (remaining <= 0) {
userSessionService.removeSession(sessionId);
httpSession.invalidate();
SecurityContextHolder.clearContext();
response.sendRedirect(contextPath + "/login?expired=true");
return;
}
// 3. lastAccessTime 갱신 (60초 스로틀링)
Long lastUpdate = (Long) httpSession.getAttribute(LAST_DB_UPDATE_ATTR);
long now = System.currentTimeMillis();
if (lastUpdate == null || (now - lastUpdate) > DB_UPDATE_THROTTLE_MS) {
userSessionService.updateLastAccessTime(sessionId);
httpSession.setAttribute(LAST_DB_UPDATE_ATTR, now);
}
filterChain.doFilter(request, response);
}
private boolean isExcludedPath(String path) {
for (String prefix : EXCLUDED_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,33 @@
package com.eactive.apim.portal.apps.session.repository;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface UserSessionRepository extends JpaRepository<UserSession, String> {
List<UserSession> findByLoginId(String loginId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
@Modifying
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
int forceLogoutAllSessions(@Param("loginId") String loginId);
@Modifying
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
@Modifying
@Query("UPDATE UserSession s SET s.lastAccessTime = :now WHERE s.sessionId = :sessionId")
int updateLastAccessTime(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
}
@@ -0,0 +1,173 @@
package com.eactive.apim.portal.apps.session.service;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserSessionService {
private static final String PROPERTY_GROUP = "Portal";
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
private static final long CLEANUP_MARGIN_MINUTES = 1;
private final UserSessionRepository userSessionRepository;
private final PortalPropertyService portalPropertyService;
/**
* 활성 세션 존재 여부 확인 (만료되지 않고 강제 로그아웃되지 않은 세션)
*/
@Transactional(readOnly = true)
public Optional<UserSession> getActiveSession(String loginId) {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);
List<UserSession> sessions = userSessionRepository.findByLoginId(loginId);
return sessions.stream()
.filter(s -> "N".equals(s.getForceLogout()))
.filter(s -> s.getLastAccessTime().isAfter(cutoff))
.findFirst();
}
/**
* 새 세션 등록
*/
@Transactional
public void registerSession(String sessionId, String userId, String loginId, String ipAddress, String userAgent) {
UserSession session = UserSession.builder()
.sessionId(sessionId)
.userId(userId)
.loginId(loginId)
.loginTime(LocalDateTime.now())
.lastAccessTime(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(truncate(userAgent, 500))
.forceLogout("N")
.build();
userSessionRepository.save(session);
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
}
/**
* 다른 세션 강제 로그아웃 플래그 설정 (중복 로그인 방지)
*/
@Transactional
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
if (count > 0) {
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
*/
@Transactional
public void forceLogoutAllSessions(String loginId) {
int count = userSessionRepository.forceLogoutAllSessions(loginId);
if (count > 0) {
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
}
}
/**
* 현재 세션의 강제 로그아웃 여부 확인
*/
@Transactional(readOnly = true)
public boolean isForceLoggedOut(String sessionId) {
return userSessionRepository.findById(sessionId)
.map(s -> "Y".equals(s.getForceLogout()))
.orElse(false);
}
/**
* 세션 삭제
*/
@Transactional
public void removeSession(String sessionId) {
if (userSessionRepository.existsById(sessionId)) {
userSessionRepository.deleteById(sessionId);
log.debug("세션 삭제 - sessionId: {}", sessionId);
}
}
/**
* 마지막 접근 시간 갱신 (세션 연장)
*/
@Transactional
public void updateLastAccessTime(String sessionId) {
userSessionRepository.updateLastAccessTime(sessionId, LocalDateTime.now());
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
*/
public int getSessionTimeoutMinutes() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
PROPERTY_NAME,
DEFAULT_TIMEOUT_MINUTES,
"세션 타임아웃 시간 (분)"
);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
}
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@Transactional(readOnly = true)
public long getRemainingSeconds(String sessionId) {
int timeoutMinutes = getSessionTimeoutMinutes();
return userSessionRepository.findById(sessionId)
.map(s -> {
LocalDateTime expireTime = s.getLastAccessTime().plusMinutes(timeoutMinutes);
long remaining = Duration.between(LocalDateTime.now(), expireTime).getSeconds();
return Math.max(0, remaining);
})
.orElse(0L);
}
/**
* 만료 세션 정리 (1분 주기).
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
*/
@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpiredSessions() {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
if (deleted > 0) {
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
}
}
private String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}
@@ -1,28 +1,18 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,14 +23,19 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@@ -53,6 +48,7 @@ public class AccountController {
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
@PostMapping("/confirm_password")
@@ -105,6 +101,12 @@ public class AccountController {
session.removeAttribute("success");
session.removeAttribute("redirectUrl");
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
@@ -142,8 +144,8 @@ public class AccountController {
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getEmailAddr(), InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
@@ -262,7 +264,7 @@ public class AccountController {
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("registrationType", "corporate");
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("registrationType", "corporate");
return "apps/mypage/orgTransfer";
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import org.springframework.web.bind.annotation.PostMapping;
@@ -22,18 +23,22 @@ public class AuthController {
private final AuthFacade authFacade;
private final CellPhoneValidator cellPhoneValidator;
private final EmailValidator emailValidator;
private final PortalUserService portalUserService;
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
PortalUserService portalUserService) {
this.authFacade = authFacade;
this.cellPhoneValidator = cellPhoneValidator;
this.emailValidator = emailValidator;
this.portalUserService = portalUserService;
}
@PostMapping("/request_auth_number")
public ValidationResponse requestAuthNumber(
@RequestParam(required = false) String mobileNumber,
@RequestParam(required = false) String purpose,
HttpSession session) {
ValidationResponse response = new ValidationResponse();
@@ -44,6 +49,16 @@ public class AuthController {
return response;
}
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(mobileNumber)) {
response.setValid(false);
response.setMessage("이미 가입된 휴대폰 번호입니다.");
return response;
}
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
// 이전 세션 데이터 정리
@@ -29,7 +29,8 @@ public class OrgRegisterController {
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_ORG_REGISTER;
}
@@ -74,6 +75,7 @@ public class OrgRegisterController {
if (response.isValid()) {
model.addAttribute("message", response.getMessage());
model.addAttribute("registrationType", "corporate");
return MAIN_REGISTER_RESULT;
} else {
setModelForErrorOrg(model, orgDTO, response.getMessage());
@@ -91,6 +93,7 @@ public class OrgRegisterController {
model.addAttribute("portalOrg", orgDTO);
model.addAttribute("error", errorMessage);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
}
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.UserManFacade;
import com.eactive.apim.portal.common.dto.ResponseDTO;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.PostMapping;
@@ -18,12 +19,16 @@ import org.springframework.web.bind.annotation.RestController;
public class UserManRestController {
private final UserManFacade userManFacade;
private final CellPhoneValidator cellPhoneValidator;
// 1. Cancel invitation
@PostMapping("/cancel-invitation")
public ResponseDTO cancelInvitation(@RequestBody PortalUserDTO user) {
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "초대가 취소되었습니다.");
boolean notifyConsent = user.isNotifyConsent();
userManFacade.cancelInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId(), notifyConsent);
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대가 취소되었습니다.<br>취소 알림이 발송되었습니다."
: "초대가 취소되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
// 2. Inactivate user
@@ -40,25 +45,25 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "사용자가 활성화되었습니다.");
}
//5. register new user
//5. register new user (휴대폰 번호 기반 초대)
@PostMapping("/invite")
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
String email = newUser.getEmailAddr();
String mobile = newUser.getMobileNumber();
// 이메일 형식 검증
if (email == null || email.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
// 휴대폰 번호 검증
if (mobile == null || mobile.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "휴대폰 번호를 입력해주세요.");
}
if (!cellPhoneValidator.isValid(mobile, null)) {
return new ResponseDTO(400, "ERROR", "올바른 휴대폰 번호 형식을 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!email.matches(emailPattern)) {
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
}
boolean notifyConsent = newUser.isNotifyConsent();
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), mobile, notifyConsent);
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
return new ResponseDTO(200, "SUCCESS", notifyConsent
? "초대 메시지가 발송되었습니다."
: "초대가 등록되었습니다.<br>(알림 수신 미동의로 메시지는 발송되지 않았습니다.)");
}
@PostMapping("/change-role")
@@ -69,6 +74,20 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
}
// 관리자 -> 이용자 변경 (본인 제외)
@PostMapping("/revoke-manager")
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
}
// 소속 제외 -> 개인이용자로 전환
@PostMapping("/remove-from-org")
public ResponseDTO removeFromOrg(@RequestBody PortalUserDTO user) {
userManFacade.removeFromOrg(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "소속에서 제외되어 개인이용자로 변경되었습니다.");
}
// 6. Resend invitation
@PostMapping("/resend-invitation")
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
@@ -4,11 +4,14 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
@@ -16,11 +19,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
@@ -50,6 +57,7 @@ public class UserRegisterController {
private final PortalUserService portalUserService;
private final UserRegisterFacade userRegisterFacade;
private final AuthFacade authFacade;
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final AgreementsFacade agreementsFacade;
@@ -57,6 +65,7 @@ public class UserRegisterController {
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
private final PortalUserAuthService portalUserAuthService;
@GetMapping("/signup")
public String showSignupSelection() {
@@ -67,8 +76,6 @@ public class UserRegisterController {
public String getUserAgreement(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, Model model) {
boolean isInvited = false;
String orgName = null;
String email = null;
String[] parts = null;
if (invitationToken != null) {
// 8글자 토큰 사용
@@ -80,29 +87,23 @@ public class UserRegisterController {
String orgId = invitation.get().getOrgId();
Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
orgName = org.map(PortalOrg::getOrgName).orElse(null);
email = invitation.get().getInvitationEmail();
parts = email.split("@");
}
}
model.addAttribute("registrationType", isInvited ? "corporate" : "personal");
model.addAttribute("isInvited", isInvited);
model.addAttribute("orgName", orgName);
model.addAttribute("email", email);
model.addAttribute("loginId", email);
if (parts != null) {
model.addAttribute("emailId", parts[0]);
model.addAttribute("domain", parts[1]);
} else {
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
}
// 휴대폰 번호 기반 초대 전환: 이메일/로그인ID 는 가입자가 직접 입력한다
model.addAttribute("email", "");
model.addAttribute("loginId", "");
model.addAttribute("emailId", "");
model.addAttribute("domain", "");
model.addAttribute("authTtl", portalProperties.getAuthTtl());
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_USER_REGISTER;
@@ -138,8 +139,21 @@ public class UserRegisterController {
}
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
session.removeAttribute("invitationToken");
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
if (invitationToken == null) {
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
if (registered.isPresent()
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
return "redirect:/signup/verification-email";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return "redirect:/signup/complete";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
return "redirect:/signup/complete/corporate";
} catch (Exception e) {
setModelForError(session, model, agreement, portalUserRegistrationDTO,
"처리 중 오류가 발생했습니다: " + e.getMessage());
@@ -169,6 +183,57 @@ public class UserRegisterController {
return MAIN_EMAIL_COMPLETED;
}
/**
* 회원가입 직후 이메일 인증 페이지.
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
*/
@GetMapping("/signup/verification-email")
public String showSignupVerificationEmail(HttpSession session, Model model) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return "redirect:/login";
}
model.addAttribute("email", email);
return "apps/register/signupVerificationEmail";
}
/**
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
*/
@PostMapping("/signup/send-verification-code")
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
return ResponseEntity.ok(response);
}
/**
* 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다.
*/
@PostMapping("/signup/verify-email-code")
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
String code = request.get("code");
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
if (response.isValid()) {
PortalUser user = portalUserService.findByEmailAddr(email);
portalUserService.activateUser(user.getId());
session.removeAttribute("signupVerificationEmail");
}
return ResponseEntity.ok(response);
}
@GetMapping("/signup/decision")
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
HttpSession session,
@@ -181,6 +246,11 @@ public class UserRegisterController {
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("decisionToken", decodedToken);
// 이미 로그인된 상태면 수락 화면으로 직행
if (SecurityUtil.isAuthenticated()) {
return "redirect:/signup/decision_process";
}
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
@@ -188,9 +258,9 @@ public class UserRegisterController {
return "redirect:/";
}
// 이메일로 사용자 정보 조회
String email = invitation.get().getInvitationEmail();
Optional<PortalUser> portalUser = portalUserRepository.findPortalUserByEmailAddr(email);
// 휴대폰 번호로 사용자 정보 조회
String mobile = invitation.get().getInvitationMobile();
Optional<PortalUser> portalUser = portalUserRepository.findAllByMobileNumber(mobile).stream().findFirst();
if (!portalUser.isPresent()) {
model.addAttribute("error", "사용자 정보를 찾을 수 없습니다.");
@@ -219,13 +289,14 @@ public class UserRegisterController {
public String showDecisionProcessPage(HttpSession session, Model model) {
session.removeAttribute("decisionToken");
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
if (!invitation.isPresent()) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
if (!SecurityUtil.getPortalAuthenticatedUser().getLoginId().equals(invitation.get().getInvitationEmail())) {
if (mobile == null || !mobile.equals(invitation.get().getInvitationMobile())) {
model.addAttribute("error", "유효하지 않은 초대입니다");
return "redirect:/";
}
@@ -241,7 +312,9 @@ public class UserRegisterController {
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("invitationCode", invitation.get().getToken());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
@@ -260,7 +333,7 @@ public class UserRegisterController {
agreementValidator.validate(agreement, bindingResult);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationEmailAndStatus(SecurityUtil.getPortalAuthenticatedUser().getLoginId(), UserInvitationEnums.InvitationStatus.PENDING);
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
@@ -270,13 +343,20 @@ public class UserRegisterController {
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
model.addAttribute("orgName", org.get().getOrgName());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
model.addAttribute("registrationType", "corporate");
return "apps/register/userDecisionProcess";
} else {
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
portalUserAuthService.reloadCurrentAuthentication();
}
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
session.removeAttribute("pendingInvitation");
session.removeAttribute("pendingInvitationToken");
@@ -294,24 +374,6 @@ public class UserRegisterController {
return "redirect:/";
}
@GetMapping("/validate_email")
public String validateUserEmail(@RequestParam(name = "token", required = false) String token) {
String decodedToken = new String(Base64.decode(token));
try {
String decToken = encryptionUtil.decrypt(decodedToken);
String[] tokens = decToken.split(":");
boolean approvalRequired = portalUserService.activateUser(tokens[1]);
if (approvalRequired) {
return "apps/register/emailValidationResultCorpManager";
} else {
return "apps/register/emailValidationResultUser";
}
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException |
BadPaddingException e) {
throw new IllegalArgumentException("인증 정보 오류. 관리자 문의하세요.");
}
}
// 에러 발생 시 모델 세팅을 위한 private 메서드
private void setModelForError(HttpSession session, Model model, UserAgreementDTO agreement,
@@ -325,7 +387,8 @@ public class UserRegisterController {
model.addAttribute("agreement", agreement);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("msgType", "sms");
}
@@ -437,10 +500,10 @@ public class UserRegisterController {
session.removeAttribute("invitationCodeAttempts");
session.removeAttribute("invitationCodeLockoutUntil");
// 기존 사용자 여부 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
// 기존 사용자 여부 확인 (휴대폰 번호 기반)
boolean existingUser = !portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile()).isEmpty();
if (existingUser.isPresent()) {
if (existingUser) {
// 기존 사용자: 초대 수락 페이지로 이동
return "redirect:/signup/decision?invitation=" + normalizedCode;
} else {
@@ -23,6 +23,9 @@ public class PortalUserDTO implements Serializable {
private String emailAddr;
// 초대 시 알림 수신 동의 여부 (false 면 초대 레코드만 생성, 메시지 미발송)
private boolean notifyConsent;
private PortalOrgDTO portalOrg;
private UserStatus userStatus;
@@ -192,7 +192,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
// 사용자 생성 및 기관 연결
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
@@ -240,7 +240,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
portalUserRepository.save(existingUser);
agreementsFacade.deleteUserAgreements(existingUser.getId()); //기존 개인약관 동의 삭제
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(existingUser);
@@ -274,7 +274,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
portalUserRepository.save(existingUser);
// 새로운 법인용 개인정보수집동의서 저장
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(existingUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(existingUser);
@@ -7,8 +7,10 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.DurationParser;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.event.UserInvitationCancelEvent;
@@ -18,6 +20,8 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.template.service.MessageHandlerService;
@@ -52,6 +56,9 @@ public class UserManFacade {
private final MessageHandlerService messageHandlerService;
private final UserLogRepository userLogRepository;
private final PortalPropertyService portalPropertyService;
private final CellPhoneValidator cellPhoneValidator;
private final UserSessionService userSessionService;
private final PortalUserAuthService portalUserAuthService;
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
return portalUserRepository
@@ -94,23 +101,35 @@ public class UserManFacade {
dto.setMobileNumber(null);
}
public void sendInvitation(PortalUser sender, String emailAddr) {
// 이메일 형식 검증
if (emailAddr == null || emailAddr.trim().isEmpty()) {
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
/**
* 법인 개발자 초대 (휴대폰 번호 기반).
*
* @param sender 초대를 보내는 법인 관리자
* @param mobileRaw 초대 대상 휴대폰 번호 (하이픈 유무 무관)
* @param notifyConsent 알림 수신 동의 여부. false 면 초대 레코드만 생성하고 메시지 발송은 스킵한다.
*/
public void sendInvitation(PortalUser sender, String mobileRaw, boolean notifyConsent) {
// 휴대폰 번호 형식 검증
if (mobileRaw == null || mobileRaw.trim().isEmpty()) {
throw new IllegalArgumentException("휴대폰 번호를 입력해주세요.");
}
if (!cellPhoneValidator.isValid(mobileRaw, null)) {
throw new IllegalArgumentException("올바른 휴대폰 번호 형식을 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!emailAddr.matches(emailPattern)) {
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
}
// 저장 포맷(하이픈 포함, 예: 010-1234-5678)으로 정규화 — PortalUser.mobileNumber 저장 포맷과
// 일치시켜야 결정적 암호화 equality 조회가 매칭된다.
String mobile = normalizeMobile(mobileRaw);
// 중복 초대 확인
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
checkDuplicateInvitation(sender.getPortalOrg().getId(), mobile);
//기존 사용자인지 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
// 기존 사용자인지 휴대폰 번호로 확인 (mobileNumber 는 유니크가 아니므로 다건 가능)
List<PortalUser> matched = portalUserRepository.findAllByMobileNumber(mobile);
if (matched.size() > 1) {
throw new IllegalArgumentException("해당 휴대폰 번호로 가입된 계정이 여러 건 존재하여 초대할 수 없습니다. 관리자에게 문의해주세요.");
}
Optional<PortalUser> existingUser = matched.isEmpty() ? Optional.empty() : Optional.of(matched.get(0));
if (existingUser.isPresent()) {
PortalUser user = existingUser.get();
@@ -119,7 +138,12 @@ public class UserManFacade {
}
}
String token = createInvitation(sender, emailAddr);
String token = createInvitation(sender, mobile);
// 알림 수신 미동의 시 초대 레코드만 생성하고 메시지 발송은 스킵
if (!notifyConsent) {
return;
}
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
MessageRecipient recipient;
@@ -129,7 +153,7 @@ public class UserManFacade {
params.put("corpName", sender.getPortalOrg().getOrgName());
params.put("authNumber", token); // 8-character invitation code
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 휴대폰 번호 사용
if (existingUser.isPresent()) {
PortalUser user = existingUser.get();
params.put("loginId", user.getUserName());
@@ -137,26 +161,36 @@ public class UserManFacade {
params.put("url", "signup/decision?invitation=" + token);
recipient = MessageRecipient.of(user);
// MessageRecipient.of() 는 유선전화(phoneNumber)를 채우므로 휴대폰 번호로 보정
recipient.setPhone(user.getMobileNumber());
} else {
params.put("loginId", emailAddr);
params.put("NAME", emailAddr);
params.put("loginId", mobile);
params.put("NAME", mobile);
params.put("url", "signup/portalUser?invitation=" + token);
String emailUsername = emailAddr.contains("@") ? emailAddr.substring(0, emailAddr.indexOf("@")) : emailAddr;
recipient = MessageRecipient.builder()
.userId(emailAddr)
.username(emailUsername)
.userId(mobile)
.username(mobile)
.phone(mobile)
.build();
}
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
}
public String createInvitation(PortalUser sender, String emailAddr) {
/**
* 휴대폰 번호를 저장 포맷(하이픈 포함)으로 정규화한다.
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
*/
private String normalizeMobile(String raw) {
return PhoneNumberUtil.normalize(raw);
}
public String createInvitation(PortalUser sender, String mobile) {
UserInvitation newInvitation = new UserInvitation();
newInvitation.setAdminId(sender.getId());
newInvitation.setOrgId(sender.getPortalOrg().getId());
newInvitation.setInvitationEmail(emailAddr);
newInvitation.setInvitationMobile(mobile);
// Set additional required fields
newInvitation.setInvitationDate(LocalDateTime.now());
@@ -215,6 +249,10 @@ public class UserManFacade {
portalUserRepository.save(targetUser);
portalUserRepository.save(currentUser);
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
portalUserAuthService.reloadCurrentAuthentication();
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(targetUser.getEmailAddr());
recipient.setPhone(targetUser.getMobileNumber());
@@ -225,7 +263,57 @@ public class UserManFacade {
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId) {
/**
* 법인 관리자 권한 회수 (관리자 → 이용자).
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
*/
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
if (targetUser.getPortalOrg() == null
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
throw new IllegalArgumentException("잘 못 된 접근입니다");
}
if (targetUser.getId().equals(admin.getId())) {
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
}
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
}
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
portalUserRepository.save(targetUser);
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
/**
* 소속 제외 (기관에서 제외 → 개인이용자).
* 대상은 본인이 아닌 같은 기관의 사용자여야 한다. portalOrg 를 비우고 ROLE_USER 로 전환한다.
*/
public void removeFromOrg(PortalAuthenticatedUser admin, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
if (targetUser.getPortalOrg() == null
|| !targetUser.getPortalOrg().getId().equals(admin.getPortalOrg().getId())) {
throw new IllegalArgumentException("잘 못 된 접근입니다");
}
if (targetUser.getId().equals(admin.getId())) {
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
}
targetUser.setPortalOrg(null);
targetUser.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(targetUser);
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
}
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
UserInvitation invitation = userInvitationRepository.findById(invitationId)
.orElseThrow(() -> new NotFoundException("Invitation not found with id: " + invitationId));
@@ -237,17 +325,19 @@ public class UserManFacade {
throw new IllegalStateException("Invitation is not in a cancelable state");
}
// invitationId 로 사용자 이름 조회, 실패시 id 를 이름으로 대체
String name = portalUserRepository.findById(invitation.getInvitationEmail())
.map(PortalUser::getUserName)
.orElse(invitation.getInvitationEmail());
invitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(invitation);
// name 이 이메일 일 경우 @ 앞부분 만 사용 (안하면 암호화 해야함...)
if (name.contains("@")) {
name = name.substring(0, name.indexOf("@"));
// 알림 수신 미동의 시 취소 알림 메시지 발송은 스킵
if (!notifyConsent) {
return;
}
invitation.setStatus(InvitationStatus.CANCELED);
// invitationMobile 로 사용자 이름 조회, 실패시 마스킹된 번호로 대체
String name = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
.stream().findFirst()
.map(PortalUser::getUserName)
.orElse(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
messageHandlerService.publishEvent(UserInvitationCancelEvent.KEY,
MessageRecipient.builder()
@@ -260,8 +350,6 @@ public class UserManFacade {
}}
);
userInvitationRepository.save(invitation);
}
public void inactivateUser(PortalAuthenticatedUser admin, String userId) {
@@ -298,8 +386,8 @@ public class UserManFacade {
.stream()
.map(invitation -> {
PortalUserDTO dto = portalUserMapper.pendingUserVO(invitation);
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(invitation.getInvitationEmail()));
dto.setEmailAddr(null); // Clear unmasked email
dto.setMaskedMobileNumber(StringMaskingUtil.maskMobileNumber(invitation.getInvitationMobile()));
dto.setMobileNumber(null); // Clear unmasked mobile
return dto;
})
.collect(Collectors.toList());
@@ -328,32 +416,32 @@ public class UserManFacade {
existingInvitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(existingInvitation);
// 새로운 초대 생성 및 발송
sendInvitation(sender, existingInvitation.getInvitationEmail());
// 새로운 초대 생성 및 발송 (재발송 시 최초 동의값을 알 수 없으므로 발송함을 기본으로)
sendInvitation(sender, existingInvitation.getInvitationMobile(), true);
}
/**
* 중복 초대 확인
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
* 같은 기관에서 같은 휴대폰 번호로 이미 PENDING 상태인 초대가 있는지 확인
*/
private void checkDuplicateInvitation(String orgId, String emailAddr) {
private void checkDuplicateInvitation(String orgId, String mobile) {
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
emailAddr, orgId, InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndOrgIdAndStatus(
mobile, orgId, InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
throw new IllegalArgumentException("이미 초대가 진행 중인 휴대폰 번호입니다.");
}
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> otherOrgPendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
emailAddr, InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
mobile, InvitationStatus.PENDING);
if (otherOrgPendingInvitation.isPresent()
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
throw new IllegalArgumentException("해당 휴대폰 번호는 다른 기관에서 초대 진행 중입니다.");
}
}
@@ -137,12 +137,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
}
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
//
// if(existingUser != null) {
// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
// }
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
@@ -150,7 +149,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_IND);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
@@ -173,6 +172,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"입력값을 확인해 주세요.");
}
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
if(existingUser != null) {
@@ -190,7 +195,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.deleteUserAgreements(newUser.getId()); //이전에 동의한 내역은 삭제
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
invitation.setStatus(UserInvitationEnums.InvitationStatus.COMPLETED);
invitation.setCompleteDate(LocalDateTime.now());
@@ -242,7 +247,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
try {
if ("accept".equals(action)) {
// 수락 처리
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail())
PortalUser user = portalUserRepository.findAllByMobileNumber(invitation.getInvitationMobile())
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("사용자 정보를 찾을 수 없습니다."));
// 서비스 계층에 위임
@@ -253,11 +259,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
userInvitationRepository.save(invitation);
agreementsFacade.deleteUserAgreements(user.getId());
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT_ORG);
agreementsFacade.saveUserAgreements(user.getId(), AgreementType.PRIVACY_COLLECT);
ValidationResponse response = new ValidationResponse();
response.setValid(true);
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
response.setShowPopup(true);
response.setPopupType("success");
return response;
@@ -15,7 +15,7 @@ public abstract class PortalUserMapper implements GenericMapper<PortalUserDTO, P
public abstract PortalAuthenticatedUser portalUserToAuthenticatedUser(PortalUser portalUser);
@Mapping(target = "emailAddr", source = "invitationEmail")
@Mapping(target = "mobileNumber", source = "invitationMobile")
public abstract PortalUserDTO pendingUserVO(UserInvitation invitation);
public PortalUserDTO toDTO(PortalUser portalUser) {
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.common.exception.SystemException;
import com.eactive.apim.portal.common.exception.UserNotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.EncryptionUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -30,7 +31,9 @@ import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -58,21 +61,46 @@ public class PortalUserAuthService implements UserDetailsService {
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
String normalizedUsername = username != null ? username.toLowerCase() : null;
PortalUser portalUser = findByEmailAddr(normalizedUsername);
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
}
}
/**
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층)을 채운 PortalAuthenticatedUser 를 생성한다.
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
*/
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
return authenticatedUser;
}
/**
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext 의 Authentication 을 교체한다.
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 등)을 즉시 반영한다.
*/
public void reloadCurrentAuthentication() {
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
if (current == null) {
return;
}
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
UsernamePasswordAuthenticationToken newAuth =
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
newAuth.setDetails(refreshed);
SecurityContextHolder.getContext().setAuthentication(newAuth);
}
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
try {
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
@@ -122,37 +150,6 @@ public class PortalUserAuthService implements UserDetailsService {
messageHandlerService.publishEvent(UserPasswordResetEvent.KEY, recipient, params);
}
public void resendVerificationEmail(String loginId) {
PortalUser user = portalUserRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
if (user.getUserStatus().equals(PortalUserEnums.UserStatus.ACTIVE)) {
throw new IllegalArgumentException("이미 인증이 완료된 계정입니다.");
}
// Enum을 직접 전달
List<MessageRequest> messageRequests = messageRequestRepository.findByEmailAndMessageCode(
user.getLoginId(), MessageCode.USER_VERIFICATION_EMAIL
);
if (!messageRequests.isEmpty()) {
messageRequestRepository.deleteAll(messageRequests);
}
HashMap<String, Object> params = new HashMap<>();
String tokenValue = user.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + user.getId();
try {
String encToken = encryptionUtil.encrypt(tokenValue);
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
MessageRecipient recipient = createMessageRecipient(user);
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
throw new SystemException("암호화 모듈 오류");
}
}
private MessageRecipient createMessageRecipient(PortalUser user) {
MessageRecipient recipient = new MessageRecipient();
recipient.setUsername(user.getUserName());
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.service;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -24,6 +25,12 @@ import org.springframework.util.StringUtils;
@Service
@Transactional
public class PortalUserService {
/** PortalProperty 그룹명 */
private static final String PORTAL_PROPERTY_GROUP = "Portal";
/** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */
private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow";
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final PasswordEncoder passwordEncoder;
@@ -68,6 +75,33 @@ public class PortalUserService {
return portalUserRepository.existsByLoginId(normalizedLoginId);
}
/**
* 휴대폰 번호 중복 여부 확인.
* mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다.
* (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다)
*/
public boolean existsByMobileNumber(String mobileNumber) {
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
return false;
}
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
}
/**
* 회원가입 시 휴대폰 번호 중복 차단 활성 여부.
* 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다.
* - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성
* - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false)
*/
@Transactional(readOnly = true)
public boolean isMobileDuplicateCheckEnabled() {
String allow = portalPropertyService
.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP)
.getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false");
boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow);
return !allowed;
}
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
return portalUserRepository.countByPortalOrg(portalOrg) > 1;
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.config.PortalProperties;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
@ControllerAdvice
public class GlobalControllerAdvice {
@@ -21,6 +22,9 @@ public class GlobalControllerAdvice {
@Autowired
private PortalProperties portalProperties;
@Autowired
private UserSessionService userSessionService;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -58,4 +62,12 @@ public class GlobalControllerAdvice {
}
return null;
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
return userSessionService.getSessionTimeoutMinutes();
}
}
@@ -1,6 +1,8 @@
package com.eactive.apim.portal.common.exception;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -9,6 +11,8 @@ import org.springframework.web.servlet.ModelAndView;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import java.io.PrintWriter;
import java.io.StringWriter;
/**
* Created by Sungpil Hyun
@@ -16,6 +20,11 @@ import javax.servlet.http.HttpServletRequest;
@Controller
public class PortalErrorController implements ErrorController {
private final Environment environment;
public PortalErrorController(Environment environment) {
this.environment = environment;
}
@RequestMapping(value = "/error", method = {RequestMethod.POST, RequestMethod.GET})
public ModelAndView handleError(HttpServletRequest request) {
@@ -27,10 +36,134 @@ public class PortalErrorController implements ErrorController {
}
ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("status", status);
if (isProd()) {
// prod 환경에서는 내부 오류 정보를 노출하지 않고 이전과 동일한 일반 안내 메시지만 표시
modelAndView.addObject("errorTitle", "일시적인 장애로 서비스가 중단되었습니다.");
modelAndView.addObject("errorDescription", "서비스 이용에 불편을 드려 죄송합니다. 빠른 서비스를 제공할 수 있게 준비하겠습니다.");
} else {
modelAndView.addObject("errorTitle", resolveTitle(status));
modelAndView.addObject("errorDescription", resolveDescription(status));
modelAndView.addObject("errorMessage", resolveErrorMessage(request, status));
modelAndView.addObject("activeProfile", resolveActiveProfile());
// include-stacktrace=always(로컬 디버깅 프로파일)일 때만 화면에 스택 트레이스까지 노출
if (isErrorDetailEnabled()) {
Throwable throwable = resolveThrowable(request);
if (throwable != null) {
modelAndView.addObject("errorException", throwable.getClass().getName());
modelAndView.addObject("errorStackTrace", stackTraceAsString(throwable));
}
}
}
return modelAndView;
}
return new ModelAndView("redirect:/");
}
private boolean isProd() {
return environment.acceptsProfiles(Profiles.of("prod"));
}
/**
* server.error.include-stacktrace=always 인 경우에만 화면에 스택 트레이스를 노출한다.
* (로컬 디버깅 프로파일에서만 해당 값을 always 로 설정 → 운영/스테이징에는 노출되지 않음)
*/
private boolean isErrorDetailEnabled() {
String includeStacktrace = environment.getProperty("server.error.include-stacktrace", "never");
return "always".equalsIgnoreCase(includeStacktrace);
}
private String resolveActiveProfile() {
String[] activeProfiles = environment.getActiveProfiles();
if (activeProfiles.length == 0) {
return "default";
}
return String.join(", ", activeProfiles);
}
private Throwable resolveThrowable(HttpServletRequest request) {
Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
if (exception instanceof Throwable) {
return (Throwable) exception;
}
return null;
}
private String stackTraceAsString(Throwable throwable) {
StringWriter stringWriter = new StringWriter();
throwable.printStackTrace(new PrintWriter(stringWriter));
return stringWriter.toString();
}
private String resolveTitle(Object status) {
HttpStatus httpStatus = resolveHttpStatus(status);
if (httpStatus == null) {
return "서비스 처리 중 오류가 발생했습니다.";
}
if (httpStatus == HttpStatus.FORBIDDEN) {
return "접근이 거부되었습니다.";
}
if (httpStatus == HttpStatus.NOT_FOUND) {
return "요청하신 페이지를 찾을 수 없습니다.";
}
if (httpStatus.is4xxClientError()) {
return "요청을 처리할 수 없습니다.";
}
if (httpStatus.is5xxServerError()) {
return "서비스 처리 중 오류가 발생했습니다.";
}
return httpStatus.getReasonPhrase();
}
private String resolveDescription(Object status) {
HttpStatus httpStatus = resolveHttpStatus(status);
if (httpStatus == HttpStatus.FORBIDDEN) {
return "요청이 보안 검증을 통과하지 못했습니다.";
}
if (httpStatus == HttpStatus.NOT_FOUND) {
return "주소가 변경되었거나 삭제되었을 수 있습니다.";
}
if (httpStatus != null && httpStatus.is5xxServerError()) {
return "잠시 후 다시 시도해 주세요.";
}
return "요청 내용을 확인한 뒤 다시 시도해 주세요.";
}
private String resolveErrorMessage(HttpServletRequest request, Object status) {
Object requestUri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
HttpStatus httpStatus = resolveHttpStatus(status);
StringBuilder message = new StringBuilder();
message.append("HTTP ").append(status);
if (httpStatus != null) {
message.append(" ").append(httpStatus.getReasonPhrase());
}
if (requestUri != null) {
message.append(" / 요청 경로: ").append(requestUri);
}
Object exceptionMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
if (exceptionMessage != null && !exceptionMessage.toString().trim().isEmpty()) {
message.append(" / 원인: ").append(exceptionMessage);
} else if (httpStatus == HttpStatus.FORBIDDEN) {
message.append(" / 원인: 접근 권한 또는 CSRF 토큰을 확인하세요.");
}
return message.toString();
}
private HttpStatus resolveHttpStatus(Object status) {
if (status instanceof Integer) {
return HttpStatus.resolve((Integer) status);
}
try {
return HttpStatus.resolve(Integer.parseInt(status.toString()));
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,194 @@
package com.eactive.apim.portal.common.migration;
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import javax.servlet.http.HttpServletRequest;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
*
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
*
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</p>
*
* <pre>
* # 미리보기(변경 안 함)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
* # 실제 실행 (PII 컬럼)
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
* # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
* </pre>
*/
@Slf4j
@RestController
@RequestMapping("/internal/migration")
public class LegacyEncryptionMigrationController {
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")),
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient"))
);
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
private static final List<String> AUDIT_TABLES = Arrays.asList(
"PTL_USER", "ptl_faq", "ptl_org",
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application"
);
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
private final JdbcTemplate jdbcTemplate;
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) {
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
}
@PostMapping("/encrypt-legacy")
@Transactional("transactionManager")
public Map<String, Object> encryptLegacy(HttpServletRequest request,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "false") boolean includeAudit) {
assertLocalOnly(request);
assertNotBypass();
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
if (includeAudit) {
for (String table : AUDIT_TABLES) {
targets.add(new TargetTable(table, AUDIT_COLUMNS));
}
}
List<Map<String, Object>> results = new ArrayList<>();
int totalChanged = 0;
for (TargetTable target : targets) {
for (String column : target.columns) {
Map<String, Object> r = processColumn(target.table, column, dryRun);
results.add(r);
totalChanged += (int) r.get("changed");
}
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
response.put("includeAudit", includeAudit);
response.put("totalChanged", totalChanged);
response.put("results", results);
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}",
dryRun ? "dry-run" : "executed", includeAudit, totalChanged);
return response;
}
/**
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
*/
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("table", table);
r.put("column", column);
List<String> values;
try {
values = jdbcTemplate.queryForList(
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
String.class);
} catch (Exception e) {
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
r.put("distinct", 0);
r.put("changed", 0);
r.put("error", e.getMessage());
return r;
}
int changed = 0;
for (String value : values) {
String normalized;
try {
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
} catch (Exception e) {
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
continue;
}
if (normalized != null && !normalized.equals(value)) {
if (!dryRun) {
jdbcTemplate.update(
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
normalized, value);
}
changed++;
}
}
r.put("distinct", values.size());
r.put("changed", changed);
return r;
}
/**
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
*/
private void assertNotBypass() {
if (converter.isBypassMode()) {
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
throw new ResponseStatusException(HttpStatus.CONFLICT,
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
}
}
/**
* 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
*/
private void assertLocalOnly(HttpServletRequest request) {
String remote = request.getRemoteAddr();
boolean localAddr = "127.0.0.1".equals(remote)
|| "0:0:0:0:0:0:0:1".equals(remote)
|| "::1".equals(remote);
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
if (!localAddr || viaProxy) {
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
}
}
private static final class TargetTable {
final String table;
final List<String> columns;
TargetTable(String table, List<String> columns) {
this.table = table;
this.columns = columns;
}
}
}
@@ -1,8 +1,10 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
@@ -49,6 +51,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -93,9 +96,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getLoginId(), InvitationStatus.PENDING);
userInvitationRepository.findFirstByInvitationMobileAndStatus(
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
UserInvitation invitation = pendingInvitation.get();
@@ -112,6 +116,12 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
}
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 로그인 성공 시 세션 정보 로깅
logLoginSuccess(request, session, username);
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.filter.SessionValidationFilter;
import com.navercorp.lucy.security.xss.servletfilter.XssEscapeServletFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -10,10 +11,12 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@@ -33,15 +36,30 @@ public class PortalConfigSecurity {
private final PortalLogoutSuccessHandler logoutSuccessHandler;
private final SessionValidationFilter sessionValidationFilter;
@Autowired
public PortalConfigSecurity(PortalAuthenticationFailureHandler authenticationFailureHandler,
PortalAuthenticationSuccessHandler authenticationSuccessHandler,
PortalAuthenticationManager portalAuthenticationManager,
PortalLogoutSuccessHandler logoutSuccessHandler) {
PortalLogoutSuccessHandler logoutSuccessHandler,
SessionValidationFilter sessionValidationFilter) {
this.authenticationFailureHandler = authenticationFailureHandler;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.portalAuthenticationManager = portalAuthenticationManager;
this.logoutSuccessHandler = logoutSuccessHandler;
this.sessionValidationFilter = sessionValidationFilter;
}
/**
* {@link SessionValidationFilter}가 Spring Security 필터 체인에서만 동작하도록
* 서블릿 컨테이너의 자동 등록을 비활성화한다.
*/
@Bean
public FilterRegistrationBean<SessionValidationFilter> sessionValidationFilterRegistration(SessionValidationFilter filter) {
FilterRegistrationBean<SessionValidationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
@@ -64,6 +82,13 @@ public class PortalConfigSecurity {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
try {
// CSRF 토큰을 쿠키(XSRF-TOKEN) 대신 HTTP 세션에 저장한다.
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
http
.authenticationManager(portalAuthenticationManager)
.formLogin(form -> form
@@ -75,21 +100,28 @@ public class PortalConfigSecurity {
.successHandler(authenticationSuccessHandler))
.logout(logout -> logout
.logoutRequestMatcher(new AntPathRequestMatcher("/actionLogout.do", "GET"))
// LogoutHandler 는 기본 SecurityContextLogoutHandler(세션 무효화)보다 먼저 실행된다.
// 세션이 살아있는 이 시점에 DB 세션 레코드를 정리해야 중복세션 오탐("이미 접속중")을 막는다.
.addLogoutHandler(logoutSuccessHandler)
.logoutSuccessHandler(logoutSuccessHandler))
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true)로 보낸다.
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // Allow only one session per user
.maxSessionsPreventsLogin(false) // Prevent new login when session limit is reached
.expiredUrl("/login?expired") // Redirect when session expires
.sessionRegistry(sessionRegistry()) // Session registry bean to track sessions
.and()
.sessionFixation()
.changeSessionId()
);
)
.addFilterBefore(sessionValidationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
} catch (Exception e) {
@@ -97,9 +129,21 @@ public class PortalConfigSecurity {
}
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
/**
* CSRF 토큰 누락/불일치는 "권한없음"이 아니라 (대개) 세션 만료다.
* 로그인 페이지에 오래 머물러 세션 내 CSRF 토큰이 사라진 경우가 대표적이므로
* 기존 세션 만료 안내(login.html의 {@code param.expired})를 재사용해 오해를 막는다.
* 그 외 진짜 권한 위반(@Secured 등)은 기본 핸들러에 위임해 403을 유지한다.
*/
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
return (request, response, accessDeniedException) -> {
if (accessDeniedException instanceof CsrfException) {
response.sendRedirect(request.getContextPath() + "/login?expired=true");
return;
}
delegate.handle(request, response, accessDeniedException);
};
}
@Bean
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.converter.EnabledStatusConverter;
import java.util.List;
import java.util.concurrent.TimeUnit;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.format.FormatterRegistry;
@@ -18,10 +21,13 @@ import org.springframework.web.filter.CharacterEncodingFilter;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.multipart.support.MultipartFilter;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
import org.springframework.web.servlet.resource.VersionResourceResolver;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
@@ -32,6 +38,22 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
public static final String ERROR = "error";
private final Environment environment;
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
@Value("${app.resource-versioning.enabled:true}")
private boolean resourceVersioningEnabled;
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
// 개발환경에서 OFF 면 sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
@Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled;
public PortalConfigWebDispatcherServlet(Environment environment) {
this.environment = environment;
}
@Bean
@@ -61,16 +83,64 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
}
registry.addResourceHandler("/css/**").addResourceLocations("/css/", "classpath:/static/css/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/webfonts/**").addResourceLocations("/webfonts/", "classpath:/static/webfonts/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/font/**").addResourceLocations("/font/", "classpath:/static/font/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/html/**").addResourceLocations("/html/", "classpath:/static/html/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/images/**").addResourceLocations("/images/", "classpath:/static/images/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/img/**").addResourceLocations("/img/", "classpath:/static/img/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/js/**").addResourceLocations("/js/", "classpath:/static/js/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/plugins/**").addResourceLocations("/plugins/", "classpath:/static/plugins/").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
registry.addResourceHandler("/favicon.ico").addResourceLocations("/favicon.ico", "classpath:/static/favicon.ico").setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS)).resourceChain(true).addResolver(new PathResourceResolver());
/**
* 정적자원 핸들러 공통 등록.
*
* <p>콘텐츠 해시 버전닝이 켜져 있으면 {@link VersionResourceResolver} 를 체인 앞단에 추가하여
* {@code /css/main.css} 요청을 내용 해시가 포함된 {@code /css/main-<hash>.css} 로 매핑한다.
* 내용이 바뀌면 URL 이 바뀌므로 강제 새로고침 없이 브라우저 캐시가 무효화된다. Thymeleaf 의
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
*/
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
CacheControl cacheControl = isResourceCachingEnabled()
? CacheControl.maxAge(1, TimeUnit.DAYS)
: CacheControl.noStore();
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
.addResourceLocations(locations)
.setCacheControl(cacheControl)
.resourceChain(isResourceCachingEnabled());
if (isResourceVersioningEnabled()) {
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
chain.addResolver(new PathResourceResolver());
}
/**
* 정적자원 해시 버전닝 활성 여부. prod 프로파일은 토글과 무관하게 항상 ON,
* 그 외 프로파일은 {@code app.resource-versioning.enabled} 값을 따른다.
*/
private boolean isResourceVersioningEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceVersioningEnabled;
}
/**
* 정적자원 서버측 캐싱(ResourceChain 의 CachingResourceResolver) 활성 여부.
* prod 프로파일은 토글과 무관하게 항상 ON, 그 외 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
*
* <p>OFF 면 매 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
* ({@code spring.web.resources.cache} 는 브라우저 캐시 헤더만 제어할 뿐 이 서버측 캐시와는 무관하다.)</p>
*/
private boolean isResourceCachingEnabled() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return true;
}
return resourceCachingEnabled;
}
@@ -123,4 +193,13 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
/**
* Thymeleaf {@code @{...}} 링크를 해시 버전 URL 로 치환하기 위한 필터.
* {@code @EnableWebMvc} 환경에서는 자동 등록되지 않으므로 명시적으로 빈 등록한다.
*/
@Bean
public ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
return new ResourceUrlEncodingFilter();
}
}
@@ -1,10 +1,12 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringRepeatUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;
@@ -18,20 +20,37 @@ import java.time.format.DateTimeFormatter;
import java.util.Enumeration;
/**
* 로그아웃 성공 시 세션 정보를 로깅하는 핸들러
* 로그아웃 처리 핸들러.
*
* <p>DB 세션 레코드 정리와 로깅은 {@link LogoutHandler#logout}에서 수행한다. 이 시점은 기본
* {@code SecurityContextLogoutHandler}가 HTTP 세션을 무효화하기 <b>전</b>이라 세션이 유효하다.
* (성공 핸들러 {@link #onLogoutSuccess}는 무효화 <b>후</b>에 실행되어 {@code getSession(false)}가
* null 이 되므로, 거기서 정리하면 DB 행이 남아 재로그인 시 "이미 접속중" 오탐이 발생한다.)
*/
@Component
public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessHandler {
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private final UserSessionService userSessionService;
public PortalLogoutSuccessHandler(UserSessionService userSessionService) {
this.userSessionService = userSessionService;
}
/**
* 세션 무효화 전에 실행되어 DB 세션 레코드를 정리하고 로그아웃 정보를 로깅한다.
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
public void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
HttpSession session = request.getSession(false);
if (session != null) {
// DB 세션 레코드 정리 (중복세션 오탐 방지)
userSessionService.removeSession(session.getId());
StringBuilder logMessage = new StringBuilder();
logMessage.append("\n");
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
@@ -97,7 +116,14 @@ public class PortalLogoutSuccessHandler implements LogoutSuccessHandler {
sessionLogger.info(logMessage.toString());
}
}
/**
* 로그아웃(세션 무효화) 완료 후 메인으로 리다이렉트한다.
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
response.sendRedirect(request.getContextPath() + "/");
}
}
@@ -20,7 +20,7 @@ import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class InquiryAdminNotifier {
public class CommunityAdminNotifier {
private final UserInfoRepository userInfoRepository;
private final MessageHandlerService messageHandlerService;
@@ -42,7 +42,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
private final InquiryAdminNotifier adminNotifier;
private final CommunityAdminNotifier adminNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
+2 -2
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: dev
@@ -39,7 +39,7 @@ gateway:
portal:
auth-virtual-code: 654321
# auth-virtual-code: 654321
test-auth-notice-enabled: true
dev:
# application.yml의 `page:` 트리(브레드크럼/메뉴 이름) 라이브 반영
+11 -2
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
config:
activate:
on-profile: prod
@@ -11,6 +11,9 @@ spring:
cachecontrol:
max-age: 86400
public: true
# 정적자원 콘텐츠 해시 버전닝은 PortalConfigWebDispatcherServlet 가 prod 프로파일에서
# 항상 ON 으로 수행한다(app.resource-versioning.enabled 토글 무시).
# 해시 URL + 장기 캐시(max-age 86400)로 배포 시 자동 캐시 무효화.
jpa:
properties:
hibernate:
@@ -26,4 +29,10 @@ spring:
cache: false
portal:
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
app:
resource-versioning:
enabled: true
resource-caching:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
spring:
jta:
enabled: true
enabled: false
web:
resources:
cache:
+33 -4
View File
@@ -2,7 +2,9 @@ server:
servlet:
context-path: /
session:
timeout: 15m
timeout: 10m
cookie:
name: PORTAL_JSESSIONID
encoding:
charset: UTF-8
port: '39130'
@@ -33,12 +35,17 @@ spring:
max-age: 0
must-revalidate: true
no-cache: true
# 정적자원 콘텐츠 해시 버전닝(/css/main.css -> /css/main-<hash>.css)은
# @EnableWebMvc 로 WebMvcAutoConfiguration 이 비활성화되어 spring.web.resources.chain
# 설정으로는 적용되지 않는다. 실제 버전닝은 PortalConfigWebDispatcherServlet 가
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
jta:
enabled: true
enabled: false
atomikos:
properties:
log-base-dir: /logs/atomikos
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
log-base-dir: /dev/null
log-base-name: adp_tx
thymeleaf:
@@ -49,6 +56,19 @@ spring:
cache: false
main:
allow-bean-definition-overriding: true
# 정적자원 해시 버전닝 토글 (prod 제외 전 프로파일에 적용).
# false 로 변경 시 /css/main.css 가 해시 없이 그대로 서빙되어 sourceMap 디버깅 가능.
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
app:
resource-versioning:
enabled: false
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
security:
basic:
enabled: false
@@ -235,12 +255,18 @@ page:
corp:
name: "법인회원가입"
path: "/signup/portalOrg"
verification_email:
name: "이메일 인증"
path: "/signup/verification-email"
decision:
name: "법인회원 초대"
path: "/signup/decision_process"
agreements:
name: 약관
path: "#"
children:
terms:
name: "이용약관 및 개인정보처리방침"
name: "이용약관"
path: "/agreements/terms"
about:
name: 안내
@@ -310,6 +336,9 @@ page:
mypage:
name: "내 정보 관리"
path: "/mypage"
verification_email:
name: "이메일 인증"
path: "/mypage/verification-email"
user_list:
name: "이용자 관리"
path: "/users"
+16 -16
View File
@@ -5,13 +5,20 @@
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
<!-- <springProfile name="gf63">-->
<!-- <property name="LOG_PATH" value="d:/kjb-logs/${inst.Name:-devSvr00}"/>-->
<!-- </springProfile>-->
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
<!-- 콘솔 로그 JVM 파라미터 제어 -->
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
<property name="__consoleLevel.false" value="OFF"/>
<property name="__consoleLevel.FALSE" value="OFF"/>
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
@@ -50,6 +57,9 @@
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
</filter>
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
</appender>
@@ -61,6 +71,7 @@
<root level="INFO">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
</root>
<springProfile name="dev">
@@ -69,15 +80,4 @@
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<springProfile name="stage">
<root level="INFO">
<appender-ref ref="ROLLING"/>
</root>
</springProfile>
<springProfile name="stage,dev">
</springProfile>
</configuration>
File diff suppressed because it is too large Load Diff
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -14,8 +14,9 @@
const counterEl = document.getElementById('djbCommentCharCounter');
function getCsrfToken() {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
const meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
function fetchJson(url, options) {
@@ -355,16 +355,11 @@ const customPopups = {
$('#emailValidationPopup').hide();
});
},
showEmailResendPopup: function (message, loginId) {
$('#emailResendMessage').html(customPopups._sanitizeHtml(message));
$('#userLoginId').val(loginId);
$('#emailResend').show();
},
/**
* 사용자 초대 팝업 표시
* @param {Object} options - 팝업 옵션
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: email)
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: mobile, notifyConsent)
* @param {Function} options.onCancel - 취소 버튼 클릭 시 호출되는 콜백 (선택사항)
*/
showUserInvite: function (options) {
@@ -374,7 +369,8 @@ const customPopups = {
const onCancel = options.onCancel;
// 입력 필드 및 에러 초기화
$('#userInviteEmailInput').val('').removeClass('error');
$('#userInviteMobileInput').val('').removeClass('error');
$('#userInviteNotifyConsent').prop('checked', false);
$('#userInvitePopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -389,28 +385,29 @@ const customPopups = {
// 입력 필드에 포커스
setTimeout(function() {
$('#userInviteEmailInput').focus();
$('#userInviteMobileInput').focus();
}, 350);
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
$('#userInvitePopupConfirmButton').off('click').on('click', function () {
const email = $('#userInviteEmailInput').val().trim();
const mobile = $('#userInviteMobileInput').val().trim();
const notifyConsent = $('#userInviteNotifyConsent').is(':checked');
// 이메일 유효성 검사
if (!email) {
customPopups.showUserInviteError('이메일 주소를 입력해주세요.');
// 휴대폰 번호 유효성 검사
if (!mobile) {
customPopups.showUserInviteError('휴대폰 번호를 입력해주세요.');
return;
}
// 이메일 형식 검증
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
customPopups.showUserInviteError('올바른 이메일 형식이 아닙니다.');
// 휴대폰 번호 형식 검증 (하이픈 유무 모두 허용)
const mobilePattern = /^01[016-9]-?\d{3,4}-?\d{4}$/;
if (!mobilePattern.test(mobile)) {
customPopups.showUserInviteError('올바른 휴대폰 번호 형식이 아닙니다.');
return;
}
if (typeof onConfirm === 'function') {
onConfirm(email);
onConfirm(mobile, notifyConsent);
}
});
@@ -431,14 +428,14 @@ const customPopups = {
});
// Enter 키 이벤트
$('#userInviteEmailInput').off('keypress').on('keypress', function (e) {
$('#userInviteMobileInput').off('keypress').on('keypress', function (e) {
if (e.which === 13) {
$('#userInvitePopupConfirmButton').click();
}
});
// 입력 시 에러 초기화
$('#userInviteEmailInput').off('input').on('input', function () {
$('#userInviteMobileInput').off('input').on('input', function () {
customPopups.clearUserInviteError();
});
},
@@ -460,14 +457,15 @@ const customPopups = {
$('body').css('overflow', '');
// 입력 필드 초기화
$('#userInviteEmailInput').val('').removeClass('error');
$('#userInviteMobileInput').val('').removeClass('error');
$('#userInviteNotifyConsent').prop('checked', false);
$('#userInvitePopupError').removeClass('show').text('');
// 이벤트 리스너 제거
$('#userInvitePopupConfirmButton').off('click');
$('#userInvitePopupCancelButton').off('click');
$('#userInvitePopupCloseButton').off('click');
$('#userInviteEmailInput').off('keypress').off('input');
$('#userInviteMobileInput').off('keypress').off('input');
},
/**
@@ -476,7 +474,7 @@ const customPopups = {
*/
showUserInviteError: function (message) {
$('#userInvitePopupError').text(message).addClass('show');
$('#userInviteEmailInput').addClass('error');
$('#userInviteMobileInput').addClass('error');
},
/**
@@ -484,7 +482,96 @@ const customPopups = {
*/
clearUserInviteError: function () {
$('#userInvitePopupError').removeClass('show');
$('#userInviteEmailInput').removeClass('error');
$('#userInviteMobileInput').removeClass('error');
},
/**
* 초대취소 팝업 표시 (알림 수신 동의 체크박스 포함)
* @param {Object} options - 팝업 옵션
* @param {string} options.target - 취소 대상 표시 문자열 (마스킹된 이메일/휴대폰)
* @param {Function} options.onConfirm - 확인 버튼 클릭 시 호출되는 콜백 (파라미터: notifyConsent)
* @param {Function} options.onCancel - 취소(닫기) 버튼 클릭 시 호출되는 콜백 (선택사항)
*/
showCancelInvitation: function (options) {
options = options || {};
const target = options.target || '';
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
// 대상 정보 및 체크박스 초기화
$('#cancelInvitationTarget').text(target);
$('#cancelInvitationNotifyConsent').prop('checked', false);
// 팝업 표시 (modal 구조 사용)
$('#cancelInvitationPopup').show();
setTimeout(function() {
$('#cancelInvitationModalBackdrop').addClass('show');
$('#cancelInvitationModal').addClass('show');
}, 10);
// Body 스크롤 방지
$('body').css('overflow', 'hidden');
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
$('#cancelInvitationPopupConfirmButton').off('click').on('click', function () {
const notifyConsent = $('#cancelInvitationNotifyConsent').is(':checked');
if (typeof onConfirm === 'function') {
onConfirm(notifyConsent);
}
});
// 닫기 버튼 이벤트
$('#cancelInvitationPopupCancelButton').off('click').on('click', function () {
customPopups.hideCancelInvitation();
if (typeof onCancel === 'function') {
onCancel();
}
});
// X 버튼 이벤트
$('#cancelInvitationPopupCloseButton').off('click').on('click', function () {
customPopups.hideCancelInvitation();
if (typeof onCancel === 'function') {
onCancel();
}
});
// Escape 키 이벤트
$(document).off('keydown.cancelInvitation').on('keydown.cancelInvitation', function (e) {
if (e.key === 'Escape') {
customPopups.hideCancelInvitation();
if (typeof onCancel === 'function') {
onCancel();
}
}
});
},
/**
* 초대취소 팝업 숨기기
*/
hideCancelInvitation: function () {
// Modal 숨김 애니메이션
$('#cancelInvitationModalBackdrop').removeClass('show');
$('#cancelInvitationModal').removeClass('show');
// 애니메이션 완료 후 숨김
setTimeout(function() {
$('#cancelInvitationPopup').hide();
}, 300);
// Body 스크롤 복원
$('body').css('overflow', '');
// 체크박스 초기화
$('#cancelInvitationNotifyConsent').prop('checked', false);
// 이벤트 리스너 제거
$('#cancelInvitationPopupConfirmButton').off('click');
$('#cancelInvitationPopupCancelButton').off('click');
$('#cancelInvitationPopupCloseButton').off('click');
$(document).off('keydown.cancelInvitation');
},
/**
@@ -35,22 +35,22 @@
font-size: $font-size-sm;
transition: $transition-base;
// Box style for Figma design (node-id: 721-1489)
// Figma GNB 로그인 버튼: pill (#F2F5F7 배경 / #0046FF 보더·텍스트 / radius 20px)
&-box {
height: 32px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px; // 아이콘-텍스트 간격
padding: 6px 16px;
color: #212529; // 강조 텍스트 색상
gap: 4px; // 아이콘-텍스트 간격 (Figma)
padding: 6px 16px 6px 12px;
color: #0046FF; // Figma 로그인 텍스트 블루
text-decoration: none;
border: none;
border-radius: 6px;
border: 1px solid #0046FF;
border-radius: 20px;
font-family: $font-family-primary;
font-weight: $font-weight-semibold; // 600
font-size: 16px;
background: transparent;
font-weight: $font-weight-regular; // 400 (Figma)
font-size: 19px;
background: #F2F5F7; // Figma 그레이2 pill 배경
transition: $transition-base;
.user-icon {
@@ -58,6 +58,10 @@
height: 18px;
flex-shrink: 0;
}
&:hover {
background: #E9EDF2;
}
}
}
@@ -0,0 +1,43 @@
@use '../abstracts/variables' as *;
// -----------------------------------------------------------------------------
// Session Timer Component
// 인증 사용자 헤더에 남은 세션 시간을 표시. 만료 임박(<=60s) 시 경고색.
// -----------------------------------------------------------------------------
.session-timer {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
white-space: nowrap;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-gray;
.session-timer-icon {
font-size: $font-size-xs;
color: $text-gray;
}
.session-timer-text {
// 카운트다운 중 폭 흔들림 방지
font-variant-numeric: tabular-nums;
min-width: 40px;
text-align: center;
&.session-timer-warning {
color: #e03131;
font-weight: $font-weight-bold;
}
}
}
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
.session-timer-mobile {
margin-right: $spacing-sm;
font-size: $font-size-xs;
.session-timer-text {
min-width: 34px;
}
}
@@ -459,11 +459,11 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: $spacing-sm $spacing-md;
height: 40px;
padding: 0 $spacing-sm;
height: 30px;
white-space: nowrap;
border-radius: $border-radius-md;
font-size: $font-size-base;
border-radius: $border-radius-sm;
font-size: 13px;
font-weight: $font-weight-regular;
color: #000000;
border: none;
@@ -504,6 +504,15 @@
background-color: darken(#CDD4F0, 5%);
}
}
&--danger {
background-color: #F8D7DA;
color: #B02A37;
&:hover {
background-color: darken(#F8D7DA, 5%);
}
}
}
// -----------------------------------------------------------------------------
@@ -40,7 +40,7 @@
gap: 16px;
.footer-logo {
width: 114px;
width: 144px; // Figma: 제주은행 로고 144x34
height: auto;
object-fit: contain;
}
@@ -53,7 +53,7 @@
.footer-link {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
font-weight: 700; // Figma: 링크 Bold
color: #5F666C;
text-decoration: none;
transition: color 0.3s ease;
@@ -65,7 +65,7 @@
}
.footer-separator {
color: #D1D5DB;
color: #5F666C; // Figma 구분자
font-size: 16px;
user-select: none;
}
@@ -75,7 +75,7 @@
font-family: $font-family-primary;
font-size: 14px;
font-weight: 400;
color: #9CA3AF;
color: #5F666C; // Figma: 저작권 그레이
margin: 0;
line-height: 1.5;
}
@@ -103,8 +103,8 @@
font-weight: 400;
color: #5F666C;
background: #FFFFFF;
border: 1px solid #D1D5DB;
border-radius: 6px;
border: 1px solid #5F666C; // Figma: 관련사이트 박스 보더
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
appearance: none;
@@ -131,14 +131,14 @@
.footer-contact {
font-family: $font-family-primary;
font-size: 18px;
font-size: 30px; // Figma: 고객센터 30px Bold
font-weight: 700;
color: #1F2937;
color: #5F666C; // Figma 그레이
margin: 0;
line-height: 1.2;
@include respond-to('md') {
font-size: 16px;
font-size: 22px;
}
}
}
@@ -127,13 +127,14 @@ body.design-survey-active {
position: relative;
background-color: #ffffff;
z-index: $z-index-header;
height: 112px;
height: 110px; // Figma GNB 높이
transition: all 0.3s ease;
border-bottom: 1px solid #dddddd;
// Index page header with light blue background
// Figma GNB: 흰색 (히어로 시안 그라데이션과 분리)
&.index-header {
background-color: #E5F8FF;
background-color: #FFFFFF;
border-bottom: none;
}
.container {
height: 100%;
@@ -201,10 +202,10 @@ body.design-survey-active {
}
.logo-text {
font-size: 22px;
font-weight: 600;
font-size: 23px; // Figma: API Portal 23px Medium
font-weight: 500;
line-height: 32px;
color: var(--text-dark);
color: #000000;
text-decoration: none;
display: inline-flex;
align-items: center;
@@ -984,8 +985,8 @@ body.design-survey-active {
align-items: center;
gap: 6px;
text-decoration: none;
color: var(--text-dark);
font-weight: 500;
color: #212529; // Figma 강조텍스트
font-weight: 700; // Figma: 네비 Bold
font-size: 20px;
padding: 8px 16px;
border-radius: 8px;
+1
View File
@@ -35,6 +35,7 @@
@use 'components/cta' as *;
@use 'components/password-popup' as *;
@use 'components/header-auth' as *;
@use 'components/session-timer' as *;
@use 'components/tables' as *;
@use 'components/accordion' as *;
@use 'components/page-title-banner' as *;
@@ -876,6 +876,26 @@
// 모바일에서는 2개 버튼만 표시되는 경우 (이전, 수정)
display: none;
}
// 모바일: 수정 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
&.app-modify-btn {
display: none;
}
}
}
}
// PC 환경 안내 문구 - 모바일에서만 노출 ( 수정)
.app-modify-pc-only-message {
display: none;
margin: 0;
padding: 0 20px 24px;
color: $text-gray;
font-size: 14px;
text-align: center;
line-height: 1.5;
@media (max-width: $breakpoint-sm) {
display: block;
}
}
@@ -194,6 +194,22 @@
}
}
// Expected Completion Date (pending requests only)
.app-list-expect-date {
margin-left: auto;
font-family: $font-family-primary;
font-size: 14px;
font-weight: $font-weight-regular;
color: #6e7780;
white-space: nowrap;
flex-shrink: 0;
// Figma 모바일: 12px
@include respond-to('sm') {
font-size: 12px;
}
}
// App Description
.app-list-description {
font-family: $font-family-primary;
@@ -246,4 +262,23 @@
font-size: 14px;
border-radius: 8px;
}
// 모바일: 생성 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
@include respond-to('sm') {
display: none;
}
}
// PC 환경 안내 문구 - 모바일에서만 노출
.app-create-pc-only-message {
display: none;
margin: 0;
color: $text-gray;
font-size: 14px;
text-align: center;
line-height: 1.5;
@include respond-to('sm') {
display: block;
}
}
@@ -370,6 +370,7 @@
// IP Input Row - Step 1 Style
.ip-input-row {
display: flex;
align-items: center;
gap: 21px;
.ip-input {
@@ -398,7 +399,7 @@
}
.btn-add-ip {
width: auto;
width: 100px;
height: 40px;
padding: 0 16px;
background-color: #3ba4ed;
@@ -410,20 +411,23 @@
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: #2b94dd;
}
@include respond-to('md') {
width: auto;
width: 100%;
font-size: 14px;
height: 38px;
}
// Figma 모바일 (1290-3137): 89px × 40px, border-radius 8px, 12px bold
@include respond-to('sm') {
width: auto;
width: 89px;
height: 36px;
font-size: 13px;
border-radius: 8px;
@@ -446,6 +450,7 @@
.ip-item {
display: flex;
align-items: center;
gap: 21px;
padding: 0;
border-bottom: none;
@@ -474,7 +479,6 @@
background-color: $white;
display: flex;
align-items: center;
font-size: 20px;
color: $text-dark;
// Figma 모바일: 40px 높이, 14px 폰트
@@ -488,25 +492,26 @@
}
.btn-remove-ip {
width: 158px !important;
height: 60px !important;
width: 100px !important;
height: 40px !important;
background-color: #e5e7eb !important;
color: #5f666c !important;
border: none !important;
border-radius: $border-radius-lg !important;
font-size: 20px !important;
font-weight: $font-weight-bold;
border-radius: $border-radius-md !important;
font-size: 14px !important;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
display: flex !important;
align-items: center;
justify-content: center;
align-self: center;
@include respond-to('md') {
width: 100% !important;
font-size: 16px !important;
height: 50px !important;
font-size: 14px !important;
height: 38px !important;
}
// Figma 모바일 (1290-3137): 89px × 40px, border-radius 8px, 12px bold
+272 -225
View File
@@ -2,6 +2,36 @@
@use '../abstracts/color-functions' as *;
@use '../abstracts/mixins' as *;
// -----------------------------------------------------------------------------
// 캐러셀 + 검색 + 추천: 이어지는 단일 배경 (Figma 연속 배경)
// - 상단 시안 그라데이션(히어로) 연한 시안(#CFF3FF)으로 흐른
// - 하단에 파도/버블 이미지(추천) 같은 #CFF3FF 상단색으로 이음새 없이 연결
// -----------------------------------------------------------------------------
.landing-top {
position: relative;
// Figma에서 추출한 단일 연속 배경 이미지 (히어로 그라데이션 + 곡선 + 추천 파도/버블)
background-color: #D4F0FC;
background-image: url('/img/bg_landing_top.png');
background-repeat: no-repeat;
background-size: 100% 100%;
background-position: top center;
// 하위 섹션 배경 투명 wrapper 배경이 끊김 없이 이어지도록
.hero-carousel-section,
.hero-carousel-container,
.hero-slide,
.api-search-section,
.api-showcase {
background: transparent !important;
}
// 개별 섹션 배경 레이어 제거 (wrapper로 통합)
.showcase-background,
.search-background {
display: none !important;
}
}
// -----------------------------------------------------------------------------
// Hero Carousel Section - Figma Design
// -----------------------------------------------------------------------------
@@ -17,7 +47,8 @@
width: 100%;
height: 580px;
overflow: hidden;
background-color: #E9F9FF;
// Figma 히어로 배경: 상단 연한 시안 하단 스카이 시안 (실측 픽셀 기준)
background: linear-gradient(180deg, #BAF5FA 0%, #76D4FC 100%);
@include respond-to('md') {
height: 800px;
@@ -60,15 +91,15 @@
// Slide backgrounds
&[data-slide="0"] {
background: #E9F9FF;
background: transparent;
}
&[data-slide="1"] {
background: #E9F9FF;
background: transparent;
}
&[data-slide="2"] {
background: #E9F9FF;
background: transparent;
}
}
@@ -176,7 +207,7 @@
width: 178px;
height: 60px;
padding: 10px;
background: #0049B4;
background: #2A69DE;
color: #FFFFFF;
font-family: $font-family-primary;
font-size: 20px;
@@ -186,9 +217,9 @@
transition: all 0.3s ease;
&:hover {
background: darken(#0049B4, 10%);
background: darken(#2A69DE, 10%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.3);
box-shadow: 0 4px 12px rgba(42, 105, 222, 0.3);
}
&:active {
@@ -421,11 +452,13 @@
.api-search-section {
position: relative;
padding: 0;
//margin-top: -80px;
overflow: hidden;
padding: 56px 20px 64px;
overflow: visible;
background: #e9f9ff;
height: 314px;
@include respond-to('sm') {
padding: 28px 16px 32px;
}
.search-background {
position: absolute;
@@ -437,111 +470,153 @@
pointer-events: none;
}
.search-content-wrapper {
// Figma: 글래스모피즘 카드 (반투명 화이트 + 보더 + 섀도, radius 30)
.search-card {
position: relative;
z-index: 1;
max-width: 1308px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
z-index: 1;
gap: 56px;
padding: 40px 56px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.45);
border: 1px solid #FFFFFF;
box-shadow: 0 10px 24px rgba(141, 155, 185, 0.25);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
@include respond-to('md') {
flex-direction: column;
align-items: flex-start;
gap: 24px;
padding-top: 40px;
padding: 32px;
}
@include respond-to('sm') {
// Figma 모바일: 캐릭터와 텍스트 가로 배치
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding-top: 20px;
justify-content: center;
}
}
.search-character {
width: 154px;
height: 148px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
@include respond-to('md') {
width: 120px;
height: 115px;
}
@include respond-to('sm') {
// Figma 모바일: 68x65px
width: 68px;
height: 65px;
gap: 16px;
padding: 20px;
border-radius: 20px;
}
}
.search-text-content {
text-align: left;
flex-shrink: 0;
@include respond-to('md') {
text-align: center;
}
@include respond-to('sm') {
// Figma 모바일: 텍스트 왼쪽 정렬
text-align: left;
width: 100%;
}
}
.search-title {
font-size: 36px;
line-height: 1.3;
font-weight: 500;
line-height: 1.25;
color: #000000;
margin: 0;
white-space: nowrap;
@include respond-to('md') {
font-size: 28px;
}
@include respond-to('sm') {
// Figma 모바일: 16px, Bold
font-size: 16px;
line-height: 1.4;
line-height: 1.3;
color: #212529;
white-space: normal;
}
}
.search-main {
flex: 1;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
@include respond-to('sm') {
gap: 12px;
}
}
// 자주찾는 API + 보라 알약 태그
.hashtag-section {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
@include respond-to('sm') {
gap: 8px;
}
}
.hashtag-label {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 700;
color: #000000;
white-space: nowrap;
@include respond-to('sm') {
font-size: 12px;
color: #212529;
}
}
.search-input-wrapper {
position: relative;
.hashtag-list {
display: flex;
justify-content: center;
padding: 0px 0 30px;
z-index: 1;
align-items: center;
gap: 10px;
flex-wrap: wrap;
@include respond-to('md') {
padding: 30px 20px 20px;
@include respond-to('sm') {
gap: 6px;
}
}
// Figma: 보라색 알약 태그 (#4546CE)
.hashtag-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
background: #4546CE;
color: #FFFFFF;
border-radius: 999px;
font-family: $font-family-primary;
font-size: 13px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
transition: background 0.2s ease, transform 0.2s ease;
&:hover {
background: darken(#4546CE, 8%);
transform: translateY(-1px);
text-decoration: none;
}
@include respond-to('sm') {
// Figma 모바일: 좌측 27px, 우측 28px 패딩으로 좌측 정렬
justify-content: flex-start;
padding: 0px 28px 12px 27px;
padding: 4px 10px;
font-size: 11px;
}
}
// 알약 태그라 텍스트 구분자(|) 숨김
.hashtag-separator {
display: none;
}
.search-input-wrapper {
width: 100%;
}
.search-form {
width: 100%;
max-width: 816px;
@include respond-to('sm') {
// Figma 모바일: 320px 고정 너비
//width: 320px;
//max-width: 320px;
}
}
.search-box {
@@ -556,16 +631,15 @@
padding: 0 24px;
@include respond-to('md') {
height: 60px;
border: 4px solid #0049B4;
height: 64px;
border-width: 4px;
padding: 0 16px;
}
@include respond-to('sm') {
// Figma 모바일: 320x36px, 1px 파란색 테두리
height: 36px;
border: 3px solid #0049B4;
border-radius: 18px;
height: 44px;
border-width: 3px;
border-radius: 22px;
padding: 0 12px;
}
}
@@ -581,7 +655,7 @@
font-size: 17px;
font-weight: 500;
color: #000000;
padding: 0 20px;
padding: 0 12px;
&::placeholder {
color: #B3B3B3;
@@ -590,18 +664,13 @@
@include respond-to('md') {
font-size: 15px;
padding: 0 12px;
}
@include respond-to('sm') {
// Figma 모바일: 11px, Medium, placeholder 색상 #8C959F
font-size: 11px;
font-weight: 500;
padding: 0 8px;
font-size: 12px;
&::placeholder {
color: #8C959F;
font-weight: 500;
}
}
}
@@ -637,119 +706,14 @@
}
@include respond-to('sm') {
// Figma 모바일: 16x16px 아이콘
width: 16px;
height: 16px;
width: 18px;
height: 18px;
}
}
@include respond-to('sm') {
width: 20px;
height: 20px;
}
}
.hashtag-section {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 0 20px 0px;
z-index: 1;
@include respond-to('md') {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 20px 30px;
}
@include respond-to('sm') {
// Figma 모바일: 가로 배치, 작은 간격
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
padding: 0 20px 20px;
justify-content: center;
}
}
.hashtag-label {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 700;
color: #000000;
white-space: nowrap;
@include respond-to('sm') {
// Figma 모바일: 12px, Bold
font-size: 12px;
color: #212529;
}
}
.hashtag-list {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
@include respond-to('md') {
justify-content: center;
gap: 10px;
}
@include respond-to('sm') {
// Figma 모바일: 8px 간격, wrap
gap: 8px;
justify-content: left;
}
}
.hashtag-link {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #000000;
text-decoration: none;
white-space: nowrap;
transition: color 0.3s ease;
&:hover {
color: #0049B4;
text-decoration: underline;
}
@include respond-to('md') {
font-size: 14px;
}
@include respond-to('sm') {
// Figma 모바일: 12px
font-size: 12px;
color: #212529;
}
}
.hashtag-separator {
font-family: $font-family-primary;
font-size: 16px;
color: #000000;
user-select: none;
@include respond-to('md') {
font-size: 14px;
}
@include respond-to('sm') {
// Figma 모바일: 세로 구분선 (|)
font-size: 10px;
color: #212529;
display: flex;
align-items: center;
height: 10px;
width: 24px;
height: 24px;
}
}
}
@@ -863,7 +827,7 @@
font-size: 15px;
font-weight: 500;
color: #FFFFFF;
background: #008ae2;
background: #2A69DE;
text-decoration: none;
border-radius: 10px;
transition: all 0.3s ease;
@@ -910,7 +874,7 @@
}
&:hover {
background: darken(#008ae2, 10%);
background: darken(#2A69DE, 10%);
svg {
transform: translateX(4px);
@@ -952,14 +916,49 @@
flex-direction: column;
align-items: flex-start;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
gap: 0px;
cursor: pointer;
// Figma: hover 파란 세로 그라데이션 (카드4 = hover 예시 상태)
// 그라데이션은 transition 직접 적용 불가 오버레이 opacity로 over/out 부드럽게 페이드
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(180deg, #50CAFF 0%, #2088FF 100%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 0;
}
// 콘텐츠를 오버레이 위로
> * {
position: relative;
z-index: 1;
}
.card-title,
.card-description {
transition: color 0.3s ease;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border-color: #008ae2;
border-color: transparent;
box-shadow: 0 10px 28px rgba(0, 14, 27, 0.22);
&::before {
opacity: 1;
}
.card-title,
.card-description {
color: #FFFFFF;
}
}
@include respond-to('md') {
@@ -1043,7 +1042,7 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Info Section - 주은행 API 소개 (Figma Design)
// Info Section - 주은행(DJBank) API 소개 (Figma Design)
// -----------------------------------------------------------------------------
.info-section {
position: relative;
@@ -1060,13 +1059,28 @@
padding: 75px 20px 20px;
}
// Figma 배경 이미지: 좌측 블루 카드(우하단 라운드 baked), 우측은 영역(모니터)
.info-background {
position: absolute;
inset: 0;
background-image: url('/img/bg_api_intro.svg');
background-size: cover;
background-position: center;
top: 0;
bottom: 0;
left: 0;
width: 54%; // 텍스트(좌측 콘텐츠) 폭을 수용하면서 모니터와 겹치지 않는
background-image: url('/img/bg_diff_card.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: left top;
pointer-events: none;
@include respond-to('md') {
// 태블릿: 컬럼 레이아웃 전체 블루
width: 100%;
}
@include respond-to('sm') {
// 모바일: 배경 + 다크 텍스트(기존 sm 오버라이드) 유지
display: none;
}
}
//.info-point-image {
@@ -1131,7 +1145,7 @@
.info-title {
font-size: 28px;
line-height: 1.4;
color: #000000;
color: #FFFFFF; // Figma: 블루 카드 텍스트
margin: 0 0 $spacing-lg 0;
@include respond-to('md') {
@@ -1154,7 +1168,7 @@
.title-highlight {
font-weight: 700;
color: #0049B4;
color: #0FDBFF; // Figma: 브랜드 라인 시안 강조
font-size: 36px;
@include respond-to('sm') {
@@ -1172,7 +1186,7 @@
font-size: 20px;
font-weight: 400;
line-height: 1.6;
color: #000000;
color: #FFFFFF; // Figma: 블루 카드 텍스트
margin: 0 0 $spacing-3xl 0;
@include respond-to('md') {
@@ -1210,17 +1224,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 18px 52px;
padding: 0 28px;
font-family: $font-family-primary;
font-size: 24px;
font-size: 18px; // Figma: 18px Bold
font-weight: 700;
line-height: 56px;
border-radius: 20px;
line-height: 1.2;
border-radius: 14px;
text-decoration: none;
white-space: nowrap;
transition: all 0.3s ease;
min-width: 310px;
height: 128px;
min-width: 0;
height: 60px; // Figma: 버튼 높이 ~71px 카드 적정 60px
@include respond-to('sm') {
// Figma 모바일: 160x72px, 14px Bold, border-radius 8px
@@ -1251,27 +1265,32 @@
}
}
// Figma: '처음 만나는 DJBank API' = 연한 파랑 배경 + 네이비 텍스트
.action-btn-primary {
background: #0049B4;
color: #FFFFFF;
background: #D5EFFF;
color: #211894;
&:hover {
background: darken(#0049B4, 10%);
background: darken(#D5EFFF, 8%);
}
}
// Figma: '피드백/개선요청' = 진한 블루 배경 + 텍스트, 테두리 없음
.action-btn-secondary {
background: #00ACDD;
background: #0046FF;
color: #FFFFFF;
border: none;
// hover 반전: 배경 + 블루 텍스트
&:hover {
background: darken(#00ACDD, 10%);
background: #FFFFFF;
color: #0046FF;
}
}
.info-image {
flex: 1;
max-width: 560px;
flex: 0 1 460px;
max-width: 460px; // 모니터 일러스트 축소 블루 카드 영역 침범 방지
display: flex;
align-items: center;
justify-content: center;
@@ -1331,6 +1350,8 @@
}
.support-background {
// Figma: 깔끔한 #eef7fd 단색 배경. 광주은행 장식 이미지 제거.
display: none;
position: absolute;
top: 50%;
left: 50%;
@@ -1696,7 +1717,8 @@
// FAQ Card Specifics
&--faq {
background: #DAF0FF;
background: #CAEAFF;
border: 1px solid #97D5FF;
.card-content h3 {
color: #212529;
@@ -1705,7 +1727,8 @@
// Q&A Card Specifics
&--qna {
background: #D9F6F8;
background: #C7F6F9;
border: 1px solid #99E0CC;
.card-content h3 {
color: #212529;
@@ -2074,7 +2097,7 @@
padding: 10px;
min-height: 60px;
width: 178px;
background: #008ae2;
background: #4685EF;
color: #FFFFFF;
font-family: $font-family-primary;
font-size: 20px;
@@ -2084,9 +2107,9 @@
transition: all 0.3s ease;
&:hover {
background: darken(#008ae2, 10%);
background: darken(#4685EF, 10%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 138, 226, 0.3);
box-shadow: 0 4px 12px rgba(70, 133, 239, 0.3);
}
&:active {
@@ -2105,6 +2128,30 @@
}
}
// Figma 1:236 / 3D 캐릭터 일러스트 (image4 / image6)
.cta-character {
position: absolute;
bottom: 0;
z-index: 1;
height: auto;
pointer-events: none;
user-select: none;
@include respond-to('md') {
display: none;
}
}
.cta-character-left {
left: 0;
width: clamp(180px, 21.6vw, 416px);
}
.cta-character-right {
right: 0;
width: clamp(180px, 21.1vw, 406px);
}
.cta-divider {
position: absolute;
bottom: 0;
@@ -7,7 +7,7 @@
// -----------------------------------------------------------------------------
.signup-selection-page {
min-height: calc(100vh - 140px); // Account for header and footer
min-height: calc(100vh - 380px); // Account for header and footer
display: flex;
align-items: center;
justify-content: center;
@@ -86,6 +86,15 @@
@media (max-width: $breakpoint-sm) {
padding: 0;
// 탭이 가로폭을 넘어갈 경우 가로 스크롤 허용
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; // Firefox
&::-webkit-scrollbar {
display: none; // Chrome/Safari
}
}
.tab-link {
@@ -107,8 +116,11 @@
border-radius: 12px 12px 0 0;
@media (max-width: $breakpoint-sm) {
padding: $spacing-md $spacing-md;
font-size: $font-size-base;
// 글자 유지 + 글자 크기/높이 축소
flex: 1 0 auto;
white-space: nowrap;
padding: $spacing-sm $spacing-md;
font-size: $font-size-sm;
gap: $spacing-xs;
}
@@ -130,6 +142,14 @@
}
}
// Empty state (해당 약관이 아직 시드되지 않은 경우)
.terms-empty {
padding: $spacing-xl $spacing-lg;
text-align: center;
color: #8c959f;
font-size: $font-size-base;
}
// Version Selector (Figma: 검색창 986-2208)
.terms-selector {
padding: $spacing-lg;
@@ -101,16 +101,15 @@
// Current user badge (used in actions column)
.user-current-badge {
display: inline-flex;
height: 40px;
height: 30px;
align-items: center;
gap: 6px;
padding: 6px 12px;
padding: 0 $spacing-sm;
background-color: rgba($accent-purple, 0.1);
color: $accent-purple;
border-radius: $border-radius-sm;
font-size: $font-size-sm;
font-size: 13px;
font-weight: $font-weight-medium;
min-width: 116px;
// 모바일에서 숨김
@media (max-width: $breakpoint-sm) {
@@ -1058,6 +1057,19 @@
& + .dropdown-item {
border-top: 1px solid #eee;
}
&--danger {
color: #B02A37;
&:hover {
background: #fdeaec;
color: #B02A37;
}
&:active {
background: #f8d7da;
}
}
}
}
@@ -19,6 +19,14 @@
<i class="fas fa-info-circle"></i>
<span>로그아웃되었습니다.</span>
</div>
<div th:if="${param.expired}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
</div>
<div th:if="${param.forceLogout}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
@@ -107,20 +115,43 @@
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
$.ajax({
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
}
});
} else {
form.submit();
}
},
error: function () {
// 중복 체크 실패 시 로그인은 그대로 진행
form.submit();
}
});
}
function fnInit() {
let form = document.getElementById('loginForm');
if (form) {
getId(form);
}
if (document.loginForm.message && document.loginForm.message.value) {
if (document.loginForm.message.value.includes("이메일 인증이 완료되지 않았습니다")) {
console.log("이메일 재발송 팝업 트리거");
let loginId = $('#loginId').val();
console.log("Retrieved loginId:", loginId);
customPopups.showEmailResendPopup(document.loginForm.message.value, loginId);
} else {
customPopups.showAlert(document.loginForm.message.value);
}
customPopups.showAlert(document.loginForm.message.value);
}
}
@@ -136,11 +167,13 @@
// 비밀번호 만료 체크 관련 코드 추가
var successMsg = [[${session.success}]];
var passwordExpired = [[${session.passwordExpired}]];
var emailVerificationRequired = [[${session.emailVerificationRequired}]];
var redirectUrl = [[${session.redirectUrl}]];
if (successMsg) {
customPopups.showAlert(successMsg, function() {
if (passwordExpired && redirectUrl) {
// 비밀번호 만료·미인증(이메일 인증 필요) 시 전용 화면으로 이동
if ((passwordExpired || emailVerificationRequired) && redirectUrl) {
window.location.href = redirectUrl;
}
});
@@ -171,7 +204,7 @@
$('#loginLoading').removeClass('active');
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
form.submit();
checkDuplicateAndLogin(form);
}
}
form.classList.add('was-validated');
@@ -5,6 +5,8 @@
<body>
<th:block layout:fragment="contentFragment">
<!-- 캐러셀 + 검색 + 추천: 이어지는 배경 그룹 -->
<div class="landing-top">
<!-- Hero Carousel Section -->
<section class="hero-carousel-section">
<!-- Carousel Container -->
@@ -96,21 +98,27 @@
<!-- API 검색 섹션 -->
<section class="api-search-section">
<div class="search-background"></div>
<div class="search-content-wrapper">
<div class="search-character">
<img th:src="@{/img/img_search_character.png}" alt="검색">
</div>
<div class="search-card">
<div class="search-text-content">
<h2 class="search-title">
원하는 API를<br>
지금 검색해 보세요.
</h2>
</div>
</div>
<div class="search-input-wrapper">
<div class="search-main">
<div class="hashtag-section">
<span class="hashtag-label">자주찾는 API</span>
<div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
<th:block th:each="hashtag, iterStat : ${hashtags}">
<a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}"></a>
<span class="hashtag-separator" th:unless="${iterStat.last}">|</span>
</th:block>
</div>
</div>
<div class="search-input-wrapper">
<form th:action="@{/apis}" method="get" class="search-form">
<div class="search-box">
<input type="text" class="search-input" placeholder="어떤 API를 고 계신가요?" id="apiSearchInput" name="keyword">
<input type="text" class="search-input" placeholder="어떤 API를 고 계신가요?" id="apiSearchInput" name="keyword">
<button class="search-icon-button" type="submit">
<svg width="33" height="34" viewBox="0 0 33 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.1738 0C23.5534 0.000258465 30.3465 6.79327 30.3467 15.1729C30.3467 18.6141 29.2001 21.7875 27.2695 24.333C27.2733 24.3367 27.2775 24.34 27.2812 24.3438L32.1348 29.1973C33.1223 30.1849 33.1223 31.7859 32.1348 32.7734C31.1472 33.7609 29.5461 33.7609 28.5586 32.7734L23.7051 27.9199C23.6653 27.8802 23.6293 27.8376 23.5928 27.7959C21.1837 29.406 18.2889 30.3466 15.1738 30.3467C6.79392 30.3467 0 23.5528 0 15.1729C0.000201956 6.79311 6.79404 0 15.1738 0ZM15.1738 5.05762C9.58735 5.05762 5.05782 9.58642 5.05762 15.1729C5.05762 20.7595 9.58722 25.2891 15.1738 25.2891C20.7602 25.2888 25.2891 20.7593 25.2891 15.1729C25.2889 9.58658 20.7601 5.05788 15.1738 5.05762Z" fill="#0049B4"/>
@@ -118,15 +126,7 @@
</button>
</div>
</form>
</div>
<div class="hashtag-section">
<span class="hashtag-label">자주찾는 API</span>
<div class="hashtag-list" th:if="${hashtags != null and not #lists.isEmpty(hashtags)}">
<th:block th:each="hashtag, iterStat : ${hashtags}">
<a href="#" class="hashtag-link" th:data-search="${hashtag}" th:text="${'#' + hashtag}">
</a>
<span class="hashtag-separator" th:unless="${iterStat.last}">|</span>
</th:block>
</div>
</div>
</div>
</section>
@@ -136,8 +136,8 @@
<div class="showcase-background"></div>
<div class="showcase-wrapper">
<div class="section-header">
<h2 class="section-title">추천 API</h2>
<p class="section-subtitle">다양한 금융 API상품을 소개합니다.</p>
<h2 class="section-title">추천 API 그룹</h2>
<p class="section-subtitle">다양한 금융 API를 제공합니다.</p>
<a th:href="@{/apis}" class="btn-all-apis">
전체보기
<svg width="9" height="15" viewBox="0 0 9 15" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -150,7 +150,7 @@
<!-- Services 데이터를 반복하여 API 카드 표시 (최대 4개) -->
<div class="api-card" th:each="service, iterStat : ${services}"
th:if="${iterStat.index < 4}"
th:onclick="${service.id != null} ? |location.href='@{/apis(groupId=${service.id})}'| : |location.href='@{/apis}'|">
th:onclick="${service.id != null} ? |location.href='@{/apis(groupIds=${service.id})}'| : |location.href='@{/apis}'|">
<h3 class="card-title" th:text="${service.groupName}">API 서비스</h3>
<p class="card-description">
<span th:if="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}"
@@ -168,6 +168,8 @@
</div>
</div>
</section>
</div>
<!-- /landing-top -->
<!-- 정보 섹션 -->
<section class="info-section">
@@ -279,7 +281,7 @@
<div class="stats-divider"></div>
<div class="stat-card">
<p class="stat-label">API 이용 건수</p>
<p class="stat-label">API 이용 건수(지난달)</p>
<div class="stat-icon">
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="70" height="70" fill="url(#pattern0_3001_94)"/>
@@ -318,6 +320,8 @@
<!-- 지금 바로 회원가입 CTA 섹션 -->
<section class="signup-cta-section">
<div class="cta-background"></div>
<img th:src="@{/img/cta_character_left.png}" alt="" class="cta-character cta-character-left">
<img th:src="@{/img/cta_character_right.png}" alt="" class="cta-character cta-character-right">
<div class="container">
<div class="cta-content">
<h2 class="cta-title">DJBank API를 지금 바로 만나보세요.</h2>
@@ -48,6 +48,12 @@
</span>
<!-- App Name -->
<h3 class="app-list-name" th:text="${request.clientName}">앱 이름</h3>
<!-- Expected Completion Date -->
<span class="app-list-expect-date"
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
예상 완료일 : 05월 30일 (토)
</span>
</div>
<!-- App Description -->
<p class="app-list-description"
@@ -116,6 +122,9 @@
<button type="button" class="btn-app-create" id="requestApiKey">
앱 생성
</button>
<p class="app-create-pc-only-message">
앱 생성은 PC 환경에서 이용해 주세요.
</p>
</div>
</section>
@@ -166,7 +166,7 @@
</div>
</div>
<!-- Callback URL -->
<!-- Callback URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">Call Back URL</span>
@@ -180,11 +180,13 @@
placeholder="URL을 입력해 주세요.">
</div>
</div>
-->
<!-- IP Whitelist -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">화이트리스트</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<div class="ip-input-row">
@@ -458,7 +460,8 @@
form.addEventListener('submit', function(e) {
const appName = document.getElementById('appName').value.trim();
const appDescription = document.getElementById('appDescription').value.trim();
const callbackUrl = document.getElementById('callbackUrl').value.trim();
// callbackUrl: OAuth2 3-legged 전용. 현재 미사용으로 주석 처리.
// const callbackUrl = document.getElementById('callbackUrl').value.trim();
if (!appName) {
e.preventDefault();
@@ -474,22 +477,30 @@
return false;
}
// Validate URL format only if callbackUrl is provided (optional field)
if (callbackUrl && callbackUrl.trim() !== '') {
try {
new URL(callbackUrl);
} catch (error) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/api/callback');
document.getElementById('callbackUrl').focus();
return false;
}
// 화이트리스트 필수
if (ipAddresses.length === 0) {
e.preventDefault();
customPopups.showAlert('화이트리스트 IP를 1개 이상 등록해주세요.');
document.getElementById('ipWhitelistInput').focus();
return false;
}
// Validate URL format only if callbackUrl is provided (optional field)
// if (callbackUrl && callbackUrl.trim() !== '') {
// try {
// new URL(callbackUrl);
// } catch (error) {
// e.preventDefault();
// customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/api/callback');
// document.getElementById('callbackUrl').focus();
// return false;
// }
// }
// Store data in sessionStorage for later steps
sessionStorage.setItem('registration_appName', appName);
sessionStorage.setItem('registration_appDescription', appDescription);
sessionStorage.setItem('registration_callbackUrl', callbackUrl);
// sessionStorage.setItem('registration_callbackUrl', callbackUrl);
sessionStorage.setItem('registration_ipWhitelist', ipAddresses.join(','));
});
});
@@ -72,7 +72,8 @@
</div>
</div>
<!-- Callback URL -->
<!-- Callback URL (주석 처리) -->
<!--/*
<div class="info-row">
<label class="info-label">Callback URL</label>
<div class="info-value">
@@ -81,6 +82,7 @@
</div>
</div>
</div>
*/-->
<!-- IP Whitelist -->
<div class="info-row">
@@ -120,6 +122,18 @@
</div>
</div>
<!-- Expected Completion Date (if set by admin) -->
<div class="info-row"
th:if="${appRequest.approval != null and appRequest.approval.expectEndDate != null and #strings.length(appRequest.approval.expectEndDate) >= 8}">
<label class="info-label">예상 완료일</label>
<div class="info-value">
<div class="info-value-box"
th:text="|${#strings.substring(appRequest.approval.expectEndDate,0,4)}.${#strings.substring(appRequest.approval.expectEndDate,4,6)}.${#strings.substring(appRequest.approval.expectEndDate,6,8)} (${appRequest.approval.expectEndDateDayOfWeek})|">
2025.05.30 (토)
</div>
</div>
</div>
<!-- Approval Date (if approved) -->
<div class="info-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
<label class="info-label">승인일시</label>
@@ -96,7 +96,8 @@
</div>
</div>
<!-- Callback URL -->
<!-- Callback URL (주석 처리) -->
<!--/*
<div class="info-row">
<label class="info-label">Callback URL</label>
<div class="info-value">
@@ -105,6 +106,7 @@
</div>
</div>
</div>
*/-->
<!-- IP Whitelist -->
<div class="info-row">
@@ -159,11 +161,14 @@
인증키 삭제
</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
class="btn btn-submit btn-primary"
class="btn btn-submit btn-primary app-modify-btn"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">
수정
</a>
</div>
<p class="app-modify-pc-only-message" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
앱 수정은 PC 환경에서 이용해 주세요.
</p>
</section>
@@ -317,6 +317,7 @@
type: 'POST',
data: {
mobileNumber: mobileNumber,
purpose: 'signup',
_csrf: $('input[name="_csrf"]').val()
},
success: function(response) {
@@ -129,6 +129,14 @@
}
};
// 제출 대상 org 필드 (검증 필수목록과 별개 - 선택입력 포함 전 필드 전송)
// 제외: ipWhitelist(가입 미입력), orgCode(관리자/시스템 부여)
const submitOrgFields = [
'compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files',
'ceoName', 'orgAddr', 'serviceName', 'orgPhoneNumber', 'scPhoneNumber',
'orgSectors', 'orgIndustryType'
];
// 이메일 유효성 검사 함수
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -177,7 +185,7 @@
}
});
fields.org.forEach(fieldId => {
submitOrgFields.forEach(fieldId => {
const element = document.getElementById(fieldId);
if (element) {
if (element.type === 'file') {
@@ -237,6 +245,14 @@
form.appendChild(input);
}
if (document.getElementById('notificationConsent')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'notificationConsent';
input.value = document.getElementById('notificationConsent').checked;
form.appendChild(input);
}
// 시스템 필드 추가
const csrfToken = document.querySelector('input[name="_csrf"]');
if (csrfToken) {
@@ -338,9 +354,11 @@
return false;
}
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
orgName: $('#orgName').val().trim() !== '',
@@ -365,6 +383,35 @@
return scenarioValidation && commonValidation;
}
// 미입력/미충족 항목을 구체적으로 수집
function collectFormErrors() {
const errors = [];
// 시나리오별 항목
if (registrationScenario === 'new') {
const loginIdElement = document.getElementById('loginId');
const userNameElement = document.getElementById('userName');
if (!(loginIdElement && isValidEmail(loginIdElement.value))) errors.push('이메일 아이디를 올바르게 입력해주세요.');
if (!(userNameElement && userNameElement.value.trim() !== '')) errors.push('성명을 입력해주세요.');
if (!isAuthVerified) errors.push('휴대폰 인증을 완료해주세요.');
if (document.getElementById('isPasswordValid')?.value.trim() !== 'true') errors.push('비밀번호 조건을 확인해주세요.');
if (document.getElementById('isPasswordMatch')?.value.trim() !== 'true') errors.push('비밀번호 확인이 일치하지 않습니다.');
}
// 공통 항목
const notificationConsentEl = document.getElementById('notificationConsent');
if ($('#orgName').val().trim() === '') errors.push('회사명을 입력해주세요.');
if ($('#compRegNo').val().trim() === '') errors.push('사업자등록번호를 올바르게 입력해주세요.');
if ($('#corpRegNo').val().trim() === '') errors.push('법인등록번호를 올바르게 입력해주세요.');
if ($('#compRegFile').val().trim() === '') errors.push('사업자등록증 파일을 첨부해주세요.');
if (!$('#termsOfUse').prop('checked')) errors.push('이용약관에 동의해주세요.');
if (!$('#privacyCollect').prop('checked')) errors.push('개인정보 수집·이용에 동의해주세요.');
if (notificationConsentEl && !notificationConsentEl.checked) errors.push('알림 수신에 동의해주세요.');
return errors;
}
let isSubmitting = false;
// 등록 버튼 클릭 이벤트
@@ -416,7 +463,10 @@
isPasswordMatch
});
let errorMessage = '모든 필수 항목을 입력하고 약관에 동의해주세요.'
const errors = collectFormErrors();
let errorMessage = errors.length > 0
? '아래 항목을 확인해주세요.<br><br>' + errors.map(e => '• ' + e).join('<br>')
: '모든 필수 항목을 입력하고 약관에 동의해주세요.';
customPopups.showAlert(errorMessage);
// Reset submission state since validation failed
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>회원가입</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="org-register-container">
<div class="common-title-bar">
<h2 class="common-title">이메일 인증</h2>
</div>
<div class="register-form-container">
<p class="form-hint" style="margin-bottom: 16px;">
회원가입을 완료하려면 가입하신 이메일 주소로 인증을 진행해 주세요.
</p>
<form id="signupVerificationEmailForm" role="form" name="signupVerificationEmailForm" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form">
<!-- 이메일 주소 + 인증코드 받기 버튼 (한 줄) -->
<div class="form-row" style="margin-bottom: 0">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">이메일 주소</span>
</div>
<div class="form-field-wrapper input-with-button">
<input type="email" id="email" name="email" class="form-input input-readonly"
th:value="${email}" readonly disabled="disabled">
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
</div>
</div>
<div class="form-row" style="margin-bottom: 0">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">&nbsp;</span>
</div>
<div class="form-field-wrapper">
<p class="form-hint" style="margin-top: 0;">위 이메일 주소로 인증코드가 발송됩니다.</p>
</div>
</div>
<!-- 인증번호 입력 -->
<div class="form-row" id="authCodeContainer" style="display: none;">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">인증코드 입력</span> <span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper input-with-button">
<div class="auth-input-group">
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*"
inputmode="numeric" class="form-input"
placeholder="이메일로 받은 인증코드 6자리">
<span class="auth-timer" id="certify_time">05:00</span>
</div>
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
</div>
</div>
</div>
</form>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<button type="button" class="btn-submit btn-secondary btn_cancel">나중에 인증</button>
</div>
</div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
$(document).ready(function() {
let timerInterval;
let resendTimerInterval;
let isVerified = false;
// CSRF 토큰 설정
const csrfHeaderName = /*[[${_csrf.headerName}]]*/ 'X-XSRF-TOKEN';
const csrfToken = /*[[${_csrf.token}]]*/ '';
// 서버 설정값
const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60;
// "나중에 인증" 클릭 시: 가입은 완료된 상태이므로 로그인 페이지로 이동 (로그인 후 인증 유도)
$('.btn_cancel').on('click', function() {
customPopups.showConfirm(
'이메일 인증을 나중에 하시겠습니까? 로그인 후에도 인증을 완료할 수 있습니다.',
function() {
location.href = /*[[@{/login}]]*/ '/login';
}
);
});
// 타이머 시작 함수 (인증 코드 유효 시간)
function startTimer(duration) {
let timer = duration;
clearInterval(timerInterval);
timerInterval = setInterval(function () {
let minutes = parseInt(timer / 60, 10);
let seconds = parseInt(timer % 60, 10);
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
$('#certify_time').text(minutes + ":" + seconds).css('color', '#666');
if (--timer < 0) {
clearInterval(timerInterval);
$('#certify_time').text("시간 초과").css('color', '#FF6B6B');
customPopups.showAlert("인증 시간이 만료되었습니다. 인증코드를 다시 받아주세요.");
$('.btn_send_code').prop('disabled', false).text('인증코드 재발송');
$('.btn_verify_code').prop('disabled', true);
$('#authCode').prop('readonly', true);
}
}, 1000);
}
// 재발송 타이머 시작 함수
function startResendTimer() {
let resendTimer = resendLimitSeconds;
const $button = $('.btn_send_code');
clearInterval(resendTimerInterval);
$button.prop('disabled', true);
resendTimerInterval = setInterval(function () {
if (resendTimer > 0) {
$button.text('재발송 가능 (' + resendTimer + '초)');
resendTimer--;
} else {
clearInterval(resendTimerInterval);
$button.prop('disabled', false).text('인증코드 재발송');
}
}, 1000);
}
// 테스트 환경 안내 메시지 표시
function displayTestAuthNotice(authNumber) {
const noticeHtml = `
<div class="test-env-notice" style="margin-top: 12px; padding: 12px 16px; background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px;">
<div class="test-env-notice__content" style="display: flex; align-items: flex-start; gap: 8px;">
<svg class="test-env-notice__icon" style="flex-shrink: 0; margin-top: 2px; width: 20px; height: 20px;" viewBox="0 0 20 20" fill="none">
<path d="M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM11 15H9V13H11V15ZM11 11H9V5H11V11Z" fill="#FF9800"/>
</svg>
<div class="test-env-notice__text-container" style="flex: 1;">
<p style="margin: 0; font-size: 14px; color: #E65100; font-weight: 500;">테스트 환경 안내</p>
<p style="margin: 4px 0 0 0; font-size: 13px; color: #5D4037; line-height: 1.5;">
현재 테스트 환경입니다. 실제로 이메일이 발송되지 않으며, 인증번호는
<strong style="color: #E65100; font-family: monospace; font-size: 14px; font-weight: bold;">${authNumber}</strong>
입니다.
</p>
</div>
</div>
</div>
`;
// 기존 안내 메시지 제거
$('.test-env-notice').remove();
// authCodeContainer 바로 앞에 안내 메시지 삽입
const authCodeContainer = document.getElementById('authCodeContainer');
if (authCodeContainer) {
const noticeDiv = document.createElement('div');
noticeDiv.className = 'form-row';
noticeDiv.innerHTML = `<div class="form-label-wrapper label-offset"></div><div class="form-field-wrapper">${noticeHtml}</div>`;
authCodeContainer.parentNode.insertBefore(noticeDiv, authCodeContainer);
}
}
// 인증코드 받기 버튼 클릭
$('.btn_send_code').on('click', function(e) {
e.preventDefault();
const $button = $(this);
$button.prop('disabled', true).text('발송 중...');
$.ajax({
url: /*[[@{/signup/send-verification-code}]]*/ '/signup/send-verification-code',
type: 'POST',
contentType: 'application/json',
data: '{}',
headers: {
[csrfHeaderName]: csrfToken
},
success: function(response) {
if (!response.valid) {
customPopups.showAlert(response.message || '인증코드 발송에 실패했습니다.');
$button.prop('disabled', false).text('인증코드 받기');
return;
}
// 테스트 환경 인증번호 표시
if (response.authNumber) {
displayTestAuthNotice(response.authNumber);
}
customPopups.showAlert('인증코드가 이메일로 발송되었습니다.');
$('#authCodeContainer').slideDown(300);
$('#authCode').prop('readonly', false);
$('.btn_verify_code').prop('disabled', false);
startTimer(300); // 5분 타이머
startResendTimer(); // 재발송 타이머 시작
},
error: function() {
customPopups.showAlert('인증코드 발송에 실패했습니다.');
$button.prop('disabled', false).text('인증코드 받기');
}
});
});
// 인증코드 확인 버튼 클릭
$('.btn_verify_code').on('click', function(e) {
e.preventDefault();
const authCode = $('#authCode').val().trim();
if (!authCode) {
customPopups.showAlert('인증코드를 입력해주세요.');
return;
}
if (authCode.length !== 6) {
customPopups.showAlert('6자리 인증코드를 입력해주세요.');
return;
}
$.ajax({
url: /*[[@{/signup/verify-email-code}]]*/ '/signup/verify-email-code',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ code: authCode }),
headers: {
[csrfHeaderName]: csrfToken
},
success: function(response) {
if (!response.valid) {
customPopups.showAlert(response.message || '인증코드가 일치하지 않습니다.');
return;
}
clearInterval(timerInterval);
clearInterval(resendTimerInterval);
isVerified = true;
// 인증 완료 UI 업데이트
$('#authCode').prop('disabled', true);
$('.btn_verify_code').prop('disabled', true);
$('.btn_send_code').prop('disabled', true);
$('#certify_time').text('인증 완료').css('color', '#6BCF7F');
customPopups.showAlert('이메일 인증이 완료되었습니다! 로그인 후 이용해 주세요.');
// 알림 확인 후 로그인 페이지로 이동
setTimeout(function() {
location.href = /*[[@{/login}]]*/ '/login';
}, 2000);
},
error: function() {
customPopups.showAlert('인증코드 확인에 실패했습니다.');
}
});
});
// 인증코드 입력 필드 숫자만 허용
$('#authCode').on('input', function() {
$(this).val($(this).val().replace(/[^0-9]/g, ''));
});
});
</script>
</th:block>
</html>
@@ -7,7 +7,7 @@
<th:block th:fragment="agreementContent(termsOfUse, privacyCollect)">
<!-- Agreement Section Header -->
<div class="org-section-header org-section-header--agreement">
<h3>약관 동의</h3>
<h3 th:text="${agreementTitle} ?: '약관 동의'">약관 동의</h3>
<span class="required-badge">필수 동의</span>
</div>
@@ -71,6 +71,28 @@
<div class="editor-content" th:utext="${privacyCollect?.contents ?: '개인정보수집동의서 내용을 불러올 수 없습니다.'}"></div>
</div>
</div>
<!-- Notification Consent (모델에 notificationConsent가 있을 때만 노출) -->
<th:block th:if="${notificationConsent != null}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="notificationConsent" id="notificationConsent" class="agreement-checkbox-input" required>
<span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span>
<span class="agreement-title">알림 수신 동의</span>
</span>
</label>
<button type="button" class="agreement-toggle-icon" data-target="notificationConsentContent">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div id="notificationConsentContent" class="agreement-content" style="display:none;">
<div class="agreement-scroll">
<div class="editor-content" th:utext="${notificationConsent?.contents ?: '알림 수신 동의서 내용을 불러올 수 없습니다.'}"></div>
</div>
</div>
</th:block>
</div>
</form>
</th:block>
@@ -32,16 +32,6 @@
</p>
</div>
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
<p class="info-text" style="margin: 0;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
<div class="result-info-box" style="background-color: #e7f3ff; border-color: #1a73e8; margin-top: 16px;">
<p class="info-text" style="margin: 0; color: #1a73e8;">
<i class="fas fa-info-circle"></i>
@@ -43,15 +43,6 @@
</p>
</div>
<div class="result-info-box" style="background-color: #fff3cd; border-color: #ffc107; margin-top: 16px;">
<p class="info-text" style="margin: 0;">
<strong>초대코드:</strong>
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 18px; font-weight: bold; color: #333; letter-spacing: 3px; margin-left: 8px;"></span>
</p>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #856404;">
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
</p>
</div>
</div>
</div>
@@ -105,6 +105,7 @@
const userNameElement = $('#userName');
const termsOfUseElement = $('#termsOfUse');
const privacyCollectElement = $('#privacyCollect');
const notificationConsentElement = $('#notificationConsent');
const mobileNumberElement = $('#mobileNumber');
const isPasswordValid = $('#isPasswordValid').val() === 'true';
const isPasswordMatch = $('#isPasswordMatch').val() === 'true';
@@ -115,6 +116,7 @@
userNameElement.val().trim() !== '' &&
termsOfUseElement.prop('checked') &&
privacyCollectElement.prop('checked') &&
(notificationConsentElement.length === 0 || notificationConsentElement.prop('checked')) &&
mobileNumberElement.val().trim() !== '' &&
isAuthVerified;
}
@@ -177,7 +179,9 @@
form.submit();
} else {
customPopups.showAlert('모든 필수 항목을 입력하고 약관에 동의해주세요.');
customPopups.showAlert(!isAuthVerified
? '인증번호확인을 완료해주세요.'
: '모든 필수 항목을 입력하고 약관에 동의해주세요.');
isSubmitting = false;
btn_register.disabled = false;
document.getElementById('loadingOverlay').classList.remove('active');
@@ -24,12 +24,16 @@
<image id="image0_1130_6799" width="163" height="129" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKMAAACBCAYAAACsCAq9AAAACXBIWXMAABcSAAAXEgFnn9JSAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACtpJREFUeNrsnUFoY1UUhm8GXYgwFmcxG2FSBZEBp6m60IU0AREGhCZrF003umzrxpW20Y1uTAtudJN04bodGBBEaLrShdoMwiAI9g24mYUQBXXhxnuSk87rTJPc8969L/e+9x94lOkkzXt53/vPOfeee65SMJgnVsJXcN6++emXsv5BR0UfCxNe1tfH4M2XXujhGwOMtuGr62NFH9UpAKopYNJxSx89DegAWAFGCYAEXFMfa6yANq2rj32oJmA0gbDNSrjg+OMifbQ0lF1gBhgfhnBTHxsZQHiRG9+CUgJGApHiwA4nJfO0XVZKxJRFhFGDuKN/bHt0SuS6GxrIPtArCIzslg84O/bR1hFLPmqXcgrikccgknVYtWF5VcYYiJVATrmrFXIdGOZMGQMEkaypz3sTGObPTdsCMeLMlxSrRodWL/Igi+N/U1asj0N92MiM2xrIJlDMiZvWN5MGstMoDEFFCcWeBi8SfjYNoNNMTj3l59eKnmWXcgBinTPnpBDskRKmHf/jOe5OisSpr89hGTCGHSeeqmSzKuRm120PQvPD0Ul4TjQovgMYi+WeaXpu19NkalEaKgDG+YNYZlWUWmYDzvocSSGlyQmVodWQTYdl2z6DSMZjiNLPq/J8OmAMSBWbPoMYDwnUqHJHYhuAMRyT3qzDec0Fc4LUULIxyTo/cIAxAJOoIkEw1yk3TkhawrfVAaP/Llpapb3lQw0hZ+8Sd70GGP23FcFrI89KtSTqWCmaqw4RRkmmuefTiesHgwbaI0fXChgzdtHkniUDyV0PL0PygKwARn9NAqKva5h7jq4XMHoM47GPF8CVORFgDB9GSRbd8/g6jONGDk0Ao4e25OKGz8Ekql0BjIErY1ErXwAjDAYYYYARBgOMoiy0jNsLGF3aPcFrfYZRMrPSB4x+mmRGperxdRgP1xSpa1loMEpUYtXHC9Dhw7Re4Q9bD27aUxM23PS1BEtSpxgBxvyoY9PD85dUcB8DRr9Noo5eVUtzT52yo2sFjHOwfUlG7VkfRMny2n7RpjSDg1FYgkW24UPsyA9F2dFDBxgDUUfKXDseZNDSpgNdwBiGSfvkVOflrmP9xUUgFnFXhCBh5BslVY7trJtyxhpAScOEliqghVwoQTdMqh6drIBM0YmsW9RazGBh5BuWZClqh1vpuY4Rk4A4KKoqkuWhc+2pSlYUQVn5uu3WxdwwflslaxbqtG8kYMwmUz1J8Sco9myldY3cxq6tkq9ZKWxfxtzAyCDsqPTbshGUt7jrg+nnkiKPG8ynWThF7nm56Ot2crMpUcIusZPAINdN88KRenSAnaC7pkYlarZW7tWw62q+YAxxUyKlsI9g/mAMFEiAmFcYAwMSIOYdRgcxpG0bMIiHwO+85XapKu80sKXs7O9ny/qcNQPEIiljTCHJXbfV/BdoFXr3K8B4HsomQ5l1V68eu+UIuAHGh5Mbmq6jQepyBhC2MH4IGE2Vkpaz2tzigtSP4sE9KCFgTKqWVQazomRDQvHZmsOi7xcNGN0lPQvsyssXuN9hZlzEamwYDAaDwU27jxErMRd8LeaKq8JYUfHPP/nnANkzYJyVnBB8K/wzi3HGiOG8o0ZFswC0iDByxfWqsltvaMMIyFucdUdAL6cw8k6r4zHDEPZNIRgxHpkXGLnkv6mymU1xaeTO91gxB4AxPDe8pvwsD0tjA4aysOumS4FBuK2Kse1tV1lYsQgY3bjjtrI7hxySUu4WxX2XPIZwXGGzrYptFFM2iqCSlzwFkVzxCUAcGg1PHRThQksegthmRZyHW5RU3WQduzbyvlzhMc9iwwPlfqC6zwdtcNRToym8forzHs/qEJxL6sFUowuFzDWMJU9ArDKIC47go5mPzKblYtOQK5x42YAzXS+em7fjU6NLse+6GvuexonS8dlD+/VbUWFg5Ipr222OCbp9voGRB9dYVul78sgXdN28beNzxw9z1zWYpTnfJJtrmyMG0OtBY3brG0o+dWnej2cE4bayPzFAn9/SUPZyBaNFECNWjW5I8VFs6GrDAEozF+0OwougXLetlKWAQQwSwgRQ9jiTHswAMU2T0qRGKrkTLIwWQBzOTORtQTxDSd/Lauxhm90vcpSYdNT8ZqgopqxpKAdBwWihqefQPaDs6hyIPjS5GjCQ/SBgTJk1D9gl74LAMxDHTex9qd9MDWQpIxDT9N120ggeiugfkKUMQKQv7jThE2wWvANEn4xCqOUkMWQW04FJZ1a63NYOdt7ayu9GqGW+5+LZIqdVO7wnShUgWlPFugqjwr3KQ01+uOkUcSJAnOyeT1Q4630G7K6NRz5cKmMHIFq1TRXWwjN6eETDeE6Ukd2zdH8+Wh3XAHMTVTFpEjhvWzRVx0sOQBQ/EYqHb0DdVFVcCPTcjVlw4aalrYrH3f8xfDPZ1gI+9yYre7YwxhbWSwwD2rMzaC9ixYUnH1fVG1dU5dnL0rcazZvbHmeUJi2H2IZipq14IW9vPKPa714fAknW+/kP1fjoRzX4+z+Tt1PxRzczZeSlA1WpewZrM63qA4id95bOQBye1ItXhr+zqYw23bQ0aUGcaJZFz3W2Zeft5ydCV3/tquRaZl6HFTfNsaLkCe7BPRvZXEEkCEkVLV5LPwtllKriFjhzA+M4ycgCxO63v0v+5MwkLLUyxiqUTa2L7NmcLcmL2+9cV5v1xVFArhOLrS/uSoEZwnzw4cvDmHCa9X/7a/j3BXYtCzfdFL6+BcbcxHZjEMdQjWM9UyDpPUefvjpz6IZArL3/vWkmbayMNtz0hlAVI6Bj31ZuPD3R3ZokGqYgEtgJQDSyVDDycE5Z8Baooit/Hht2uQjIaZDR/512a0Ygrn92xwmINpRRMk3Vgyq6M3KdSVSPfkf/Nw3mOIguLS2MkuWRe0DGnVEyYQJk+eoTZ7+jrNsERILQAojHzmDkHQZMs70I44rJBM/0heQ6KZabBeTBB68Mf9KwzdEnZiBKM/J5KOOq4LUAMZmJwhoCsvHxD1NjOnLLJ5+/PnMqj/6GZRB7LmGsCl67D64S2GjJp2jKNLr/78xsN+6qp6msZUXsO4GR17eYZtERBrndKspFyUzS4RcTd59I4Q2WriZVRokqwkU7DvwnASlNOsYQWwbRmIGkMEpq7OCiM7iRF77xu/vGQDoE0ZgB18o4gItOHTdGaYA0GR9M49YNXXTfCYyxhupO4h3YhZZqjJaA3D08nfh/DkEkM551S6KMkrKmY3BkRR17aR/srS/vPgIk/dvl9B6rYtf0xUmqdpYEr4WLtqsw1bRAtr76VVWeu6yi+/8Mh4EyOGdjEy/i1276yPRL0fFiCQxZtJu3bTbkd209rYqi5k9J3LTp0wlVtG9UIR/CuqFEi+1EMPJaF7jo+cWOdJMbQTw0CXZCkCqjBMZ7oMdZMuPzEt9dSdKSBkZJJg1ldAdkV/lZqEy7aCVebCeFUdpDB+YOyB3PgCQQUym2FManoIzeAemDy26lBdGpm0a3iExd9rIS1j5azJobtnbJksIY4e57CWSfgcxynxx6CKgRqLWqLCmMpjVJKBvLHsgBJw+Ljr9/yuZrQ7dsYYu2uIlmSAR7utSy2mgcNsEe7LAq3Up4mhLuu9reVwwjAzlrmzA0ifcPTAKSalCrgri/rx5sfN6zrYJWYIwBOX7q4iffwirAIOAclwHGW+49WG/jUP2m2f8CDAD1bzaEOOLAWAAAAABJRU5ErkJggg=="/>
</defs>
</svg>
<h2 class="result-title">회원가입 신청이 완료되었습니다.</h2>
<h2 class="result-title" th:text="${registrationType == 'corporate'} ? '법인 신청이 완료 되었습니다.' : '회원가입 신청이 완료되었습니다.'">회원가입 신청이 완료되었습니다.</h2>
</div>
<!-- Info Box -->
<div class="result-info-box">
<p class="info-text">
<p class="info-text" th:if="${registrationType == 'corporate'}">
관리자의 확인 및 승인 이후<br>
이용하실 수 있습니다.
</p>
<p class="info-text" th:unless="${registrationType == 'corporate'}">
API Portal 서비스를 이용하기 위해서<br>
<strong>이메일 인증</strong>을 완료해 주세요.
</p>
@@ -329,17 +329,10 @@
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// Get CSRF token from cookie
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
function getCsrfToken() {
var name = 'XSRF-TOKEN=';
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i].trim();
if (cookie.indexOf(name) === 0) {
return decodeURIComponent(cookie.substring(name.length));
}
}
return '';
var meta = document.querySelector('meta[name="_csrf"]');
return meta ? meta.getAttribute('content') : '';
}
// Validate date range (max 40 days)
@@ -31,7 +31,7 @@
<div class="list-table-header">
<div class="header-cell" style="width: 90px;">NO.</div>
<div class="header-cell" style="width: 90px;">성명</div>
<div class="header-cell" style="flex: 1; min-width: 180px;">이메일 아이디</div>
<div class="header-cell" style="flex: 1; min-width: 180px;">이메일 / 휴대폰</div>
<div class="header-cell" style="width: 120px;">권한</div>
<div class="header-cell" style="width: 120px;">계정상태</div>
<div class="header-cell" style="width: 320px;">설정</div>
@@ -71,18 +71,24 @@
th:data-user-email="${user.maskedEmailAddr}">
관리자 권한부여
</button>
<button type="button" class="list-table-btn list-table-btn--default revoke_manager"
th:if="${user.userStatus.toString() == 'ACTIVE' && user.roleCode.toString() == 'ROLE_CORP_MANAGER'}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
이용자로 변경
</button>
<button type="button" class="list-table-btn list-table-btn--danger remove_from_org"
th:if="${user.userStatus.toString() == 'ACTIVE' && (user.roleCode.toString() == 'ROLE_CORP_USER' || user.roleCode.toString() == 'ROLE_CORP_MANAGER')}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
소속 제외
</button>
<button type="button" class="list-table-btn list-table-btn--primary activate_user"
th:if="${user.userStatus.toString() != 'ACTIVE'}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
활성화
</button>
<button type="button" class="list-table-btn list-table-btn--primary inactivate_user"
th:if="${user.userStatus.toString() == 'ACTIVE'}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
비활성화
</button>
<button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation"
th:if="${user.userStatus.toString() == 'PENDING'}"
th:data-user-id="${user.id}"
@@ -108,18 +114,24 @@
th:data-user-email="${user.maskedEmailAddr}">
관리자 권한부여
</button>
<button type="button" class="dropdown-item revoke_manager"
th:if="${user.userStatus.toString() == 'ACTIVE' && user.roleCode.toString() == 'ROLE_CORP_MANAGER'}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
이용자로 변경
</button>
<button type="button" class="dropdown-item dropdown-item--danger remove_from_org"
th:if="${user.userStatus.toString() == 'ACTIVE' && (user.roleCode.toString() == 'ROLE_CORP_USER' || user.roleCode.toString() == 'ROLE_CORP_MANAGER')}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
소속 제외
</button>
<button type="button" class="dropdown-item activate_user"
th:if="${user.userStatus.toString() != 'ACTIVE'}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
활성화
</button>
<button type="button" class="dropdown-item inactivate_user"
th:if="${user.userStatus.toString() == 'ACTIVE'}"
th:data-user-id="${user.id}"
th:data-user-name="${user.maskedUserName}">
비활성화
</button>
<button type="button" class="dropdown-item cancel_invitation"
th:if="${user.userStatus.toString() == 'PENDING'}"
th:data-user-id="${user.id}"
@@ -137,8 +149,8 @@
<div class="list-table-row" th:each="user : ${pendingUsers}">
<div class="row-cell" style="width: 90px;" data-label="NO">-</div>
<div class="row-cell" style="width: 90px;" data-label="성명">-</div>
<div class="row-cell" style="flex: 1; min-width: 180px;" data-label="이메일" th:text="${user.maskedEmailAddr}">
pending@example.com
<div class="row-cell" style="flex: 1; min-width: 180px;" data-label="휴대폰" th:text="${user.maskedMobileNumber}">
010-****-****
</div>
<div class="row-cell" style="width: 120px;" data-label="권한">이용자</div>
<div class="row-cell" style="width: 120px;" data-label="계정상태">초대중</div>
@@ -147,7 +159,7 @@
<div class="actions-desktop">
<button type="button" class="list-table-btn list-table-btn--secondary cancel_invitation"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedEmailAddr}">
th:data-user-email="${user.maskedMobileNumber}">
초대취소
</button>
</div>
@@ -164,7 +176,7 @@
<div class="dropdown-menu">
<button type="button" class="dropdown-item cancel_invitation"
th:data-user-id="${user.id}"
th:data-user-email="${user.maskedEmailAddr}">
th:data-user-email="${user.maskedMobileNumber}">
초대취소
</button>
</div>
@@ -194,6 +206,8 @@
<div th:replace="~{fragment/popup/userInvitePopup :: userInvitePopup}"></div>
<!-- Change Role Modal -->
<div th:replace="~{fragment/popup/changeRolePopup :: changeRolePopup}"></div>
<!-- Cancel Invitation Modal -->
<div th:replace="~{fragment/popup/cancelInvitationPopup :: cancelInvitationPopup}"></div>
</section>
</body>
@@ -247,12 +261,12 @@
// Function to open the user invite popup
function openUserInvitePopup() {
customPopups.showUserInvite({
onConfirm: function(email) {
onConfirm: function(mobile, notifyConsent) {
$.ajax({
url: '/users/invite',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({emailAddr: email})
data: JSON.stringify({mobileNumber: mobile, notifyConsent: notifyConsent})
}).done(function(response) {
customPopups.hideUserInvite();
customPopups.showAlert(response.msg, function() {
@@ -315,6 +329,63 @@
});
});
// 관리자 -> 이용자 변경 (본인 제외)
document.querySelectorAll('.revoke_manager').forEach(button => {
button.addEventListener('click', function(event) {
const btn = event.currentTarget;
const userId = btn.dataset.userId;
const userName = btn.dataset.userName;
customPopups.showConfirm(`<strong>${userName}</strong>님을 이용자로 변경하시겠습니까?`, function(selection) {
if (!selection) return;
$.ajax({
url: '/users/revoke-manager',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({id: userId})
}).done(function(response) {
customPopups.showAlert(response.msg, function() {
location.reload();
});
}).fail(function(jqXHR, textStatus, errorThrown) {
let msg;
try { msg = JSON.parse(jqXHR.responseText).msg; } catch (e) {}
customPopups.showAlert(msg || ('권한 변경 중 오류가 발생했습니다: ' + errorThrown));
});
});
});
});
// 소속 제외 -> 개인이용자 전환 (경고)
document.querySelectorAll('.remove_from_org').forEach(button => {
button.addEventListener('click', function(event) {
const btn = event.currentTarget;
const userId = btn.dataset.userId;
const userName = btn.dataset.userName;
customPopups.showConfirm(
`<strong>${userName}</strong>님을 소속에서 제외하시겠습니까?<br>제외 시 기관의 모든 권한이 회수되며 <strong>개인이용자</strong>로 전환됩니다.`,
function(selection) {
if (!selection) return;
$.ajax({
url: '/users/remove-from-org',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({id: userId})
}).done(function(response) {
customPopups.showAlert(response.msg, function() {
location.reload();
});
}).fail(function(jqXHR, textStatus, errorThrown) {
let msg;
try { msg = JSON.parse(jqXHR.responseText).msg; } catch (e) {}
customPopups.showAlert(msg || ('소속 제외 중 오류가 발생했습니다: ' + errorThrown));
});
}
);
});
});
const activateButtons = document.querySelectorAll('.activate_user');
const inactivateButtons = document.querySelectorAll('.inactivate_user');
const cancelInvitationButtons = document.querySelectorAll('.cancel_invitation');
@@ -363,21 +434,24 @@
const userId = button.dataset.userId;
const userEmail = button.dataset.userEmail;
customPopups.showConfirm(`${userEmail}에 대한 초대를 취소하시겠습니까?`, function(selection) {
if (selection) {
customPopups.showCancelInvitation({
target: userEmail,
onConfirm: function(notifyConsent) {
$.ajax({
url: '/users/cancel-invitation',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({id: userId})
data: JSON.stringify({id: userId, notifyConsent: notifyConsent})
}).done(function(response) {
customPopups.hideCancelInvitation();
customPopups.showAlert(response.msg, function() {
location.reload(); // Reload the page to reflect changes
});
}).fail(function(jqXHR, textStatus, errorThrown) {
customPopups.showAlert('초대 취소 중 오류가 발생했습니다: ' + errorThrown);
}).always(function() {
$('#customConfirm').hide();
customPopups.hideCancelInvitation();
let msg;
try { msg = JSON.parse(jqXHR.responseText).msg; } catch (e) {}
customPopups.showAlert(msg || ('초대 취소 중 오류가 발생했습니다: ' + errorThrown));
});
}
});
+69 -8
View File
@@ -150,6 +150,47 @@
white-space: pre-wrap;
}
/* Dev Error Detail (운영 미노출) */
.error-detail-wrap {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.error-dev-notice {
background-color: #fffaf0;
border: 1px solid #f6e05e;
border-radius: 8px;
padding: 12px 18px;
font-size: 14px;
line-height: 1.6;
color: #975a16;
text-align: left;
}
.error-dev-notice .error-env {
display: inline-block;
margin-top: 4px;
}
.error-dev-notice .error-env strong {
color: #c05621;
}
/* Stack Trace */
.error-stacktrace-text {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #742a2a;
line-height: 1.5;
text-align: left;
white-space: pre;
overflow: auto;
max-height: 360px;
margin: 0;
}
/* Responsive */
@media screen and (max-width: 600px) {
.error-container {
@@ -210,18 +251,38 @@
</div>
<!-- Error Title -->
<h1 class="error-title">일시적인 장애로 서비스가 중단되었습니다.</h1>
<h1 class="error-title" th:text="${errorTitle ?: '서비스 처리 중 오류가 발생했습니다.'}">
서비스 처리 중 오류가 발생했습니다.
</h1>
<!-- Error Description -->
<p class="error-description">
서비스 이용에 불편을 드려 죄송합니다.<br>
빠른 서비스를 제공할 수 있게 준비하겠습니다.
<p class="error-description" th:text="${errorDescription ?: '잠시 후 다시 시도해 주세요.'}">
잠시 후 다시 시도해 주세요.
</p>
<!-- Error Message (서버에서 전달된 에러 메시지가 있을 경우 표시) -->
<div th:if="${errorMessage}" class="error-message-box">
<p class="error-message-label">오류 상세</p>
<p class="error-message-text" th:text="${errorMessage}"></p>
<!-- 상세 오류 정보 (개발 환경 전용) - 운영(prod)에서는 표시되지 않음 -->
<div th:if="${errorMessage}" class="error-detail-wrap">
<!-- 운영 미노출 안내 + 현재 환경 -->
<p class="error-dev-notice">
⚠ 아래 상세 정보는 개발 편의를 위한 것으로,
<strong>운영 환경에서는 표시되지 않습니다.</strong>
<span class="error-env">현재 환경: <strong th:text="${activeProfile} ?: 'default'">-</strong></span>
</p>
<!-- 오류 메시지 -->
<div class="error-message-box">
<p class="error-message-label">오류 상세</p>
<p class="error-message-text" th:text="${errorMessage}"></p>
</div>
<!-- 스택 트레이스 (include-stacktrace=always 이고 예외가 있을 때만) -->
<div th:if="${errorStackTrace}" class="error-message-box">
<p class="error-message-label">
<span>스택 트레이스</span>
<span th:if="${errorException}" th:text="'(' + ${errorException} + ')'"></span>
</p>
<pre class="error-stacktrace-text" th:text="${errorStackTrace}"></pre>
</div>
</div>
<!-- Action Buttons -->
@@ -10,9 +10,10 @@
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a th:href="@{/agreements/terms(tab=privacy)}" class="footer-link">개인정보처리방침</a>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do"
target="_blank" rel="noopener noreferrer" class="footer-link">개인정보처리방침</a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 DJ Bank. All Rights Reserved.</p>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>
<!-- Right Section -->
@@ -6,6 +6,10 @@
<meta content="https://www.eactive.co.kr/" property="og:url"/>
<meta charset="UTF-8"/>
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
<meta content="index, follow" name="robots"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
@@ -78,6 +82,12 @@
<script th:src="@{/plugins/summernote/summernote-lite.min.js}"></script>
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
<!-- LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. -->
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
</head>
</html>
@@ -64,6 +64,11 @@
<!-- Logout State (Authenticated) -->
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
<div class="header-user-info">
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
</span>
<span class="divider"></span>
<div class="user-identity">
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
@@ -102,6 +107,10 @@
</div>
<div class="mobile-right">
<span class="session-timer session-timer-mobile" sec:authorize="isAuthenticated()" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text">--:--</span>
</span>
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
@@ -243,6 +252,155 @@
<!-- Mobile Menu & Dropdown JavaScript -->
<th:block th:fragment="headerScript">
<!-- 세션 타이머 / 유휴 자동 로그아웃 / 연장 확인 (인증 사용자만) -->
<script sec:authorize="isAuthenticated()" th:inline="javascript">
(function () {
var STATUS_URL = /*[[@{/api/session/status}]]*/ '/api/session/status';
var HEARTBEAT_URL = /*[[@{/api/session/heartbeat}]]*/ '/api/session/heartbeat';
var EXPIRED_URL = /*[[@{/login?expired=true}]]*/ '/login?expired=true';
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
var remainingSeconds = timeoutMinutes * 60;
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
// 데스크톱·모바일 타이머 텍스트를 모두 갱신 (id 대신 클래스 사용)
var timerTexts = document.querySelectorAll('.session-timer-text');
var countdownTimer = null;
var promptShown = false; // 연장 확인 모달 중복 표시 방지
var redirecting = false;
function getCookie(name) {
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return m ? decodeURIComponent(m.pop()) : '';
}
function csrfHeaders() {
// 세션 기반 CSRF: 쿠키 대신 <meta>에서 토큰·헤더명을 읽는다.
var tokenMeta = document.querySelector('meta[name="_csrf"]');
var headerMeta = document.querySelector('meta[name="_csrf_header"]');
var headers = {};
if (tokenMeta && tokenMeta.content) {
var headerName = (headerMeta && headerMeta.content) ? headerMeta.content : 'X-XSRF-TOKEN';
headers[headerName] = tokenMeta.content;
}
return headers;
}
function formatTime(sec) {
if (sec < 0) sec = 0;
var m = Math.floor(sec / 60);
var s = sec % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}
function render() {
if (!timerTexts.length) return;
var label = formatTime(remainingSeconds);
var warn = remainingSeconds <= WARNING_SECONDS;
for (var i = 0; i < timerTexts.length; i++) {
timerTexts[i].textContent = label;
if (warn) {
timerTexts[i].classList.add('session-timer-warning');
} else {
timerTexts[i].classList.remove('session-timer-warning');
}
}
}
function goTo(url) {
if (redirecting) return;
redirecting = true;
if (countdownTimer) clearInterval(countdownTimer);
window.location.href = url;
}
function extendSession() {
$.ajax({
url: HEARTBEAT_URL,
type: 'POST',
headers: csrfHeaders(),
success: function (data) {
if (data && data.remainingSeconds > 0) {
remainingSeconds = data.remainingSeconds;
promptShown = false;
render();
} else {
goTo(EXPIRED_URL);
}
},
error: function () {
// 연장 실패 시 만료로 처리
goTo(EXPIRED_URL);
}
});
}
function showExtendConfirm() {
if (typeof customPopups === 'undefined' || !customPopups.showConfirm) {
return; // 팝업 미로딩 시 카운트다운만으로 만료 처리
}
var $yes = $('#customConfirmYesButton');
var $no = $('#customConfirmNoButton');
var prevYes = $yes.text();
var prevNo = $no.text();
$yes.text('연장하기');
$no.text('로그아웃');
customPopups.showConfirm('세션이 곧 만료됩니다. 로그인 상태를 연장하시겠습니까?', function (extend) {
// 버튼 라벨 원복 (다른 confirm 팝업에 영향 없도록)
$yes.text(prevYes);
$no.text(prevNo);
if (extend) {
extendSession();
} else {
goTo(LOGOUT_URL);
}
});
}
function tick() {
remainingSeconds--;
if (remainingSeconds <= 0) {
goTo(EXPIRED_URL);
return;
}
if (remainingSeconds <= WARNING_SECONDS && !promptShown) {
promptShown = true;
showExtendConfirm();
}
render();
}
function pollStatus() {
$.ajax({
url: STATUS_URL,
type: 'GET',
success: function (data) {
if (!data || !data.valid) {
goTo(FORCE_LOGOUT_URL);
return;
}
remainingSeconds = data.remainingSeconds;
if (remainingSeconds > WARNING_SECONDS) {
promptShown = false; // 다른 탭/활동으로 연장된 경우 모달 재무장
}
render();
},
error: function () {
// 폴링 실패 시 로컬 카운트다운 유지
}
});
}
// 초기화
render();
countdownTimer = setInterval(tick, 1000);
setInterval(pollStatus, POLL_INTERVAL_MS);
})();
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Mobile Drawer
@@ -5,14 +5,14 @@
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1 th:text="${agreementTitle}">이용 약관</h1>
<h1 th:text="${isTermsOfUse ? '이용약관' : (isPrivacyCollect ? '개인정보수집동의서' : '알림 수신 동의서')}">이용 약관</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="terms-container">
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title" th:text="${isTermsOfUse ? '이용약관' : '개인정보처리방침'}">이용약관</h2>
<h2 class="common-title" th:text="${isTermsOfUse ? '이용약관' : (isPrivacyCollect ? '개인정보수집동의서' : '알림 수신 동의서')}">이용약관</h2>
</div>
<!-- Tab Navigation -->
@@ -22,15 +22,20 @@
th:classappend="${isTermsOfUse ? 'active' : ''}">
이용약관
</a>
<a th:href="@{/agreements/terms(tab='privacy')}"
<a th:href="@{/agreements/terms(tab='privacy-collect')}"
class="tab-link"
th:classappend="${isPrivacyPolicy ? 'active' : ''}">
개인정보처리방침
th:classappend="${isPrivacyCollect ? 'active' : ''}">
개인정보수집동의서
</a>
<a th:href="@{/agreements/terms(tab='notification')}"
class="tab-link"
th:classappend="${isNotification ? 'active' : ''}">
알림 수신 동의서
</a>
</div>
<!-- Version Selector -->
<div class="terms-selector">
<div class="terms-selector" th:if="${selectedAgreement != null}">
<form id="agreementForm" th:action="@{/agreements/terms}" method="get">
<input type="hidden" name="tab" th:value="${currentTab}">
<select name="publishedOn" id="publishedOnSelect" class="terms-select" onchange="this.form.submit()">
@@ -45,19 +50,24 @@
<!-- Terms Content -->
<div class="terms-content">
<!-- Attachment Download -->
<div class="terms-attachment" th:if="${selectedAgreement.attachedFileId != null}">
<a th:href="@{/file/download(fileId=${selectedAgreement.attachedFileId}, fileSn=1)}"
class="attachment-link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span th:text="${selectedAgreement.attachedFileName} ?: '첨부파일 다운로드'">첨부파일 다운로드</span>
</a>
<th:block th:if="${selectedAgreement != null}">
<!-- Attachment Download -->
<div class="terms-attachment" th:if="${selectedAgreement.attachedFileId != null}">
<a th:href="@{/file/download(fileId=${selectedAgreement.attachedFileId}, fileSn=1)}"
class="attachment-link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span th:text="${selectedAgreement.attachedFileName} ?: '첨부파일 다운로드'">첨부파일 다운로드</span>
</a>
</div>
<div class="content-body editor-content" th:utext="${selectedAgreement.contents}"></div>
</th:block>
<div class="terms-empty" th:if="${selectedAgreement == null}">
<p>등록된 약관이 없습니다.</p>
</div>
<div class="content-body editor-content" th:utext="${selectedAgreement.contents}"></div>
</div>
</div>
</section>
@@ -0,0 +1,50 @@
<!-- views/fragment/popup/cancelInvitationPopup.html -->
<div th:fragment="cancelInvitationPopup" id="cancelInvitationPopup" style="display: none;">
<!-- Modal Backdrop -->
<div class="modal-backdrop" id="cancelInvitationModalBackdrop"></div>
<!-- Modal Wrapper -->
<div class="modal" id="cancelInvitationModal">
<div class="modal-dialog">
<!-- Modal Header -->
<div class="modal-header">
<h3 class="modal-title">초대취소</h3>
<button type="button" class="modal-close" id="cancelInvitationPopupCloseButton">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="modal-body">
<p id="cancelInvitationPopupMessage" style="text-align: center; margin-bottom: 24px; color: #1A1A2E; font-size: 16px;">
<strong id="cancelInvitationTarget" style="color: #4B9BFF;"></strong>에 대한<br>초대를 취소하시겠습니까?
</p>
<!-- 알림 수신 동의 체크박스 -->
<div class="pop_consent_group" style="margin-top: 16px;">
<label class="pop_consent_label"
style="display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #475569; line-height: 1.5; cursor: pointer;">
<input type="checkbox" id="cancelInvitationNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
<span>
초대 취소 사실을 수신자에게 알림 메시지로 전송하기 위해 수신자의
<a th:href="@{/agreements/terms(tab='notification')}" target="_blank" rel="noopener noreferrer"
style="color: #3ba4ed; text-decoration: underline;">알림 수신 동의서</a>
동의가 필요합니다. 동의하지 않을 경우 초대 취소는 진행되나 메시지가 발송되지 않습니다.
</span>
</label>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-submit" id="cancelInvitationPopupCancelButton">닫기</button>
<button type="button" class="btn btn-primary btn-submit" id="cancelInvitationPopupConfirmButton">초대취소</button>
</div>
</div>
</div>
</div>
@@ -3,6 +3,5 @@
<div th:replace="fragment/popup/customPopup :: #customAlert"></div>
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
<div th:replace="fragment/popup/emailResendPopup :: emailResendPopup"></div>
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
</div>
@@ -1,63 +0,0 @@
<section class="content pop" th:fragment="emailResendPopup">
<div class="popup_total" id="emailResend" style="display:none; z-index:1001;">
<form id="emailResendForm">
<input type="hidden" id="userLoginId" name="loginId" value="">
<input type="hidden" name="_csrf" id="csrfToken" th:value="${_csrf.token}"/>
<div class="pop_dim">
<div class="popup-content">
<div class="inner_txt">
<div class="title">
<h2 id="customAlertTitle2">알림</h2>
</div>
</div>
<p class="pop_text" id="emailResendMessage"></p>
<div class="pop_btnbox">
<button type="button" class="popup_button_gray" id="emailResendButton">
이메일 재발송
</button>
<button type="button" class="popup_button_blue" id="confirmButton">확인</button>
</div>
<a class="btn_close" style="margin-top:0" id="emailResendPopupCloseButton">
<img th:src="@{/img/icon/icon_close.png}" alt="닫기">
</a>
</div>
</div>
</form>
</div>
<script type="text/javascript">
// 이메일 재발송 버튼 클릭 이벤트
document.getElementById('emailResendButton').addEventListener('click', function (e) {
e.preventDefault();
const loginId = document.getElementById('userLoginId').value;
const csrfToken = document.getElementById('csrfToken').value;
// 팝업 닫기
$('#emailResend').hide();
// Ajax 요청
$.ajax({
url: '/resend_verification_email',
type: 'POST',
data: {
loginId: loginId,
_csrf: csrfToken
},
success: function (response) {
var message = response.success || response.error || '이메일 발송에 실패했습니다.';
customPopups.showAlert(message);
},
error: function (xhr, status, error) {
console.error('요청 실패:', error);
customPopups.showAlert('이메일 발송 중 오류가 발생했습니다.');
}
});
});
// 확인 버튼과 닫기 버튼 이벤트
$('#confirmButton, #emailResendPopupCloseButton').on('click', function () {
$('#emailResend').hide();
});
</script>
</section>
@@ -21,17 +21,32 @@
<!-- Modal Body -->
<div class="modal-body">
<p id="userInvitePopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
추가할 이용자 e-mail 주소를 입력해 주세요.
추가할 이용자 휴대폰 번호를 입력해 주세요.
</p>
<!-- Email Input Field -->
<!-- Mobile Input Field -->
<div class="pop_input_group">
<input type="email"
id="userInviteEmailInput"
<input type="tel"
id="userInviteMobileInput"
class="pop_input_field"
placeholder="이용자 e-mail 주소">
placeholder="휴대폰 번호 ('-' 없이 입력)"
maxlength="13">
<div id="userInvitePopupError" class="error-message"></div>
</div>
<!-- 알림 수신 동의 체크박스 -->
<div class="pop_consent_group" style="margin-top: 16px;">
<label class="pop_consent_label"
style="display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #475569; line-height: 1.5; cursor: pointer;">
<input type="checkbox" id="userInviteNotifyConsent" style="margin-top: 3px; flex-shrink: 0;">
<span>
입력한 휴대폰 번호로 초대 메시지를 보내기 위해 수신자의
<a th:href="@{/agreements/terms(tab='notification')}" target="_blank" rel="noopener noreferrer"
style="color: #3ba4ed; text-decoration: underline;">알림 수신 동의서</a>
동의가 필요합니다. 동의하지 않을 경우 초대는 진행되나 메시지가 발송되지 않습니다.
</span>
</label>
</div>
</div>
<!-- Modal Footer -->
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.controller.AccountController;
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
@@ -57,6 +58,9 @@ class AccountControllerTest {
@Mock
private UserInvitationRepository userInvitationRepository;
@Mock
private UserSessionService userSessionService;
@InjectMocks
private AccountController accountController;
@@ -113,6 +117,7 @@ class AccountControllerTest {
authenticatedUser.setRoleCode(RoleCode.ROLE_USER);
authenticatedUser.setUserStatus(UserStatus.ACTIVE);
authenticatedUser.setApprovalStatus(ApprovalStatus.COMPLETED);
authenticatedUser.setMobileNumber("01012345678");
PortalUserDTO dto = new PortalUserDTO();
dto.setId("user-1");
@@ -125,8 +130,8 @@ class AccountControllerTest {
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(authenticatedUser);
when(userFacade.findById("user-1")).thenReturn(dto);
when(userInvitationRepository.findFirstByInvitationEmailAndStatus(
"test@example.com", InvitationStatus.PENDING))
when(userInvitationRepository.findFirstByInvitationMobileAndStatus(
"010-1234-5678", InvitationStatus.PENDING))
.thenReturn(Optional.empty());
mockMvc.perform(get("/mypage"))
@@ -0,0 +1,70 @@
package com.eactive.apim.portal.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class PhoneNumberUtilTest {
@Test
@DisplayName("휴대폰 11자리 → 010-XXXX-XXXX (멱등)")
void mobile11() {
assertEquals("010-1234-5678", PhoneNumberUtil.normalize("01012345678"));
assertEquals("010-1234-5678", PhoneNumberUtil.normalize("010-1234-5678"));
assertEquals("010-9912-0000", PhoneNumberUtil.normalize("01099120000"));
}
@Test
@DisplayName("휴대폰 10자리 → 01X-XXX-XXXX")
void mobile10() {
assertEquals("011-123-4567", PhoneNumberUtil.normalize("0111234567"));
}
@Test
@DisplayName("서울 02 → 9자리 02-XXX-XXXX / 10자리 02-XXXX-XXXX")
void seoul() {
assertEquals("02-123-4567", PhoneNumberUtil.normalize("021234567"));
assertEquals("02-1234-5678", PhoneNumberUtil.normalize("0212345678"));
assertEquals("02-1234-5678", PhoneNumberUtil.normalize("02-1234-5678"));
}
@Test
@DisplayName("지역/070 → 10자리 0XX-XXX-XXXX / 11자리 0XX-XXXX-XXXX")
void area() {
assertEquals("031-234-5678", PhoneNumberUtil.normalize("0312345678"));
assertEquals("070-1234-5678", PhoneNumberUtil.normalize("07012345678"));
}
@Test
@DisplayName("안심번호 050X 12자리 → 0XXX-XXXX-XXXX")
void safetyNumber() {
assertEquals("0507-1234-5678", PhoneNumberUtil.normalize("050712345678"));
}
@Test
@DisplayName("대표번호 15XX/16XX/18XX 8자리 → XXXX-XXXX")
void representative() {
assertEquals("1588-1234", PhoneNumberUtil.normalize("15881234"));
assertEquals("1644-5678", PhoneNumberUtil.normalize("16445678"));
assertEquals("1800-1234", PhoneNumberUtil.normalize("18001234"));
}
@Test
@DisplayName("미해당 패턴/null/blank 는 원본 유지")
void passthrough() {
assertEquals("123", PhoneNumberUtil.normalize("123"));
assertEquals("abc", PhoneNumberUtil.normalize("abc"));
assertEquals("", PhoneNumberUtil.normalize(""));
assertNull(PhoneNumberUtil.normalize(null));
}
@Test
@DisplayName("digitsOnly 는 숫자만 추출, null 유지")
void digitsOnly() {
assertEquals("01012345678", PhoneNumberUtil.digitsOnly("010-1234-5678"));
assertEquals("0212345678", PhoneNumberUtil.digitsOnly("02-1234-5678"));
assertNull(PhoneNumberUtil.digitsOnly(null));
}
}