Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5585df9133 | |||
| 2c63fa1557 | |||
| d71af34950 | |||
| fbc145d145 | |||
| f36b1a6478 | |||
| 8f03209f8a | |||
| 8743592fde | |||
| a0685c8689 | |||
| c0ae60f738 | |||
| b45a7a162a | |||
| 07fbe1ba54 | |||
| 4916bdf4af | |||
| 35621b5174 | |||
| b97b0fcf7b | |||
| 6cb98d36bb | |||
| fd229fca43 | |||
| 5e8d08f1af | |||
| e7945233fe | |||
| 815e2bdd04 | |||
| 0629e842a3 | |||
| 6fa2167378 | |||
| 84e873a08b | |||
| 6c231c4189 | |||
| e3b5a44270 | |||
| 829630ae5c | |||
| 2a9694cd85 | |||
| 456b635a32 | |||
| faaf9e8cd6 | |||
| 4749613947 | |||
| 5125dab897 | |||
| 02eb81af67 | |||
| c02a5ff047 | |||
| 4181d114c1 | |||
| daff07c006 | |||
| 1f51c9b21e | |||
| 929980c089 | |||
| d4d5d66727 | |||
| d3e6249a41 | |||
| 68f8c07fc0 | |||
| 87369ce7f7 | |||
| a2f6151a08 | |||
| b29882c0b2 | |||
| 80740a9543 | |||
| cdd68f48f0 | |||
| d2bc61dc31 | |||
| 73deb7f02e | |||
| fedd3e4c50 | |||
| 6c13b6bc63 | |||
| b54fedc280 | |||
| a4316545dc | |||
| b5ffa69eba | |||
| 2106142d93 | |||
| 0f58f043a8 | |||
| 61be6bf108 | |||
| 96d6dd3df1 | |||
| f9b53810d9 | |||
| 41be827b81 | |||
| 78898ab5a8 | |||
| 19c17f4e91 | |||
| c2d84bb024 | |||
| 6434d48b05 | |||
| d5f0f3aab4 | |||
| dfc859c9cf | |||
| 24c669b0be | |||
| 3eedc522e2 | |||
| a361cc3dec | |||
| 1ac9fa5407 | |||
| 9981459691 | |||
| a2878f9cee | |||
| 1f10dda993 | |||
| a6807a37c2 | |||
| 6c10ae12c7 | |||
| 01c4a80182 | |||
| a5611cf775 | |||
| c733e6b200 | |||
| a2813f391c | |||
| f221c20ece | |||
| 4a3b5b43f3 | |||
| 8de0d94063 | |||
| 57d4efb4eb | |||
| c865359819 | |||
| 41391c8df0 | |||
| 9f4874fe44 | |||
| 43d915342e | |||
| ba00b80bfe | |||
| 87763ba325 | |||
| 5b7a4a108d | |||
| 9b9bcb5be4 | |||
| bdb249b74b | |||
| 93f7c210bc | |||
| bb79b55f08 | |||
| 23bd0c1207 | |||
| 28a9ff1a7f | |||
| 045f354e5c | |||
| 4f577c8c14 | |||
| cc4c1a6b53 | |||
| 551a2da717 | |||
| 1c57d348e6 | |||
| 2c29c8a466 | |||
| d2052d0e16 | |||
| 9c4f3cfb04 |
@@ -80,6 +80,21 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
|
||||
// 실패해도 배포는 진행하도록 UNSTABLE 로만 표시한다.
|
||||
stage('SBOM') {
|
||||
steps {
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle sbomXlsx --no-daemon -Pprofile=weblogic'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,5 +101,20 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SBOM(CycloneDX) -> xlsx. 산출물 eapim-portal-sbom.xlsx 를 아티팩트로 보관.
|
||||
// 실패해도 빌드는 진행하도록 UNSTABLE 로만 표시한다.
|
||||
stage('SBOM') {
|
||||
steps {
|
||||
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
|
||||
sh 'gradle sbomXlsx --no-daemon'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'build/reports/sbom/*.xlsx', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +521,7 @@ ls -lh src/main/resources/static/css/main.min.css # minified
|
||||
## 문서
|
||||
|
||||
- **개발환경 준비 사항**: [`djb-docs/개발환경-준비-사항.md`](djb-docs/개발환경-준비-사항.md) — JDK·Gradle·Node.js·SASS 설치 가이드
|
||||
- **메뉴 관리 개발 가이드**: [`readme-docs/메뉴-관리-개발-가이드.md`](readme-docs/메뉴-관리-개발-가이드.md) — menu.yml/roles.yml 스키마·시딩 규칙·캐시 리로드·admin 포탈메뉴관리 연동
|
||||
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
||||
- **사용자 가이드**: `개발자포탈.md` (한글)
|
||||
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
||||
|
||||
+15
-4
@@ -80,9 +80,10 @@ dependencies {
|
||||
// exclude group: 'commons-collections', module: 'commons-collections'
|
||||
}
|
||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-core:2.15.3'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.15.3'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
|
||||
// WS-2026-0003 (jackson-core async parser DoS, CVSS 7.5) — 2.18.6 에서 수정. JDK8 호환.
|
||||
implementation 'com.fasterxml.jackson.core:jackson-core:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-annotations:2.18.6'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.6'
|
||||
|
||||
implementation group: 'org.apache.velocity', name: 'velocity-engine-core', version: '2.3'
|
||||
|
||||
@@ -145,6 +146,14 @@ sourceSets {
|
||||
|
||||
configurations {
|
||||
annotationProcessor
|
||||
|
||||
// WebLogic 배포 시 Tyrus WebSocket 필터(weblogic.websocket.tyrus.TyrusServletFilter)와
|
||||
// 충돌 방지: WAR 에 번들된 Tomcat WsSci 가 javax.websocket.server.ServerContainer 속성을
|
||||
// WsServerContainer 로 등록 → WebLogic Tyrus 필터가 TyrusServerContainer 로 캐스팅하다 실패.
|
||||
// 앱은 WebSocket 미사용이므로 Tomcat WebSocket 모듈 제외.
|
||||
all {
|
||||
exclude group: 'org.apache.tomcat.embed', module: 'tomcat-embed-websocket'
|
||||
}
|
||||
}
|
||||
|
||||
compileJava {
|
||||
@@ -194,4 +203,6 @@ task printSourceSets {
|
||||
println " Output dir : ${srcSet.output.classesDirs.asPath}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx)
|
||||
apply from: "$projectDir/gradle/sbom-xlsx.gradle"
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크.
|
||||
*
|
||||
* gradle sbomXlsx # cyclonedxBom 실행 후 변환
|
||||
* gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략)
|
||||
* gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정
|
||||
*
|
||||
* buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드
|
||||
* classpath 나 WAR 산출물에는 포함되지 않는다.
|
||||
*
|
||||
* 시트: 요약 / WAR 기준
|
||||
* 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로
|
||||
* test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다.
|
||||
* bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다.
|
||||
*/
|
||||
buildscript {
|
||||
repositories {
|
||||
maven {
|
||||
url "https://nexus.eactive.synology.me:8090/repository/maven-public/"
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'org.apache.poi:poi-ooxml:3.17'
|
||||
}
|
||||
}
|
||||
|
||||
import groovy.json.JsonSlurper
|
||||
import org.apache.poi.ss.usermodel.BorderStyle
|
||||
import org.apache.poi.ss.usermodel.FillPatternType
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment
|
||||
import org.apache.poi.ss.usermodel.IndexedColors
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment
|
||||
import org.apache.poi.ss.util.CellRangeAddress
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
|
||||
// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이
|
||||
ext.SBOM_CELL_LIMIT = 32000
|
||||
|
||||
task sbomXlsx {
|
||||
group = 'sbom'
|
||||
description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다'
|
||||
|
||||
// -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다
|
||||
if (!project.hasProperty('sbomJson')) {
|
||||
dependsOn 'cyclonedxBom'
|
||||
}
|
||||
|
||||
doLast {
|
||||
File src = resolveBomJson(project)
|
||||
File out = project.hasProperty('sbomOut')
|
||||
? project.file(project.property('sbomOut'))
|
||||
: new File(project.buildDir, "reports/sbom/${sbomFileName(project)}")
|
||||
out.parentFile.mkdirs()
|
||||
|
||||
def bom = new JsonSlurper().parse(src, 'UTF-8')
|
||||
def deploy = collectDeployJars(project)
|
||||
def warRows = joinWarRows(deploy.jars, indexComponents(bom))
|
||||
|
||||
def wb = new XSSFWorkbook()
|
||||
def st = createStyles(wb)
|
||||
writeSummarySheet(wb, st, bom, warRows, src, deploy.label)
|
||||
writeWarSheet(wb, st, warRows)
|
||||
|
||||
out.withOutputStream { os -> wb.write(os) }
|
||||
wb.close()
|
||||
|
||||
int unmatched = warRows.count { it.matched == 'N' }
|
||||
logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " +
|
||||
"(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})")
|
||||
}
|
||||
}
|
||||
|
||||
/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */
|
||||
String sbomFileName(Project p) {
|
||||
def warTask = p.tasks.findByName('war')
|
||||
if (warTask != null) {
|
||||
String archive = warTask.archiveFileName.get()
|
||||
return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx'
|
||||
}
|
||||
String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}"
|
||||
return "${p.name}${ver}-sbom.xlsx"
|
||||
}
|
||||
|
||||
/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */
|
||||
File resolveBomJson(Project p) {
|
||||
if (p.hasProperty('sbomJson')) {
|
||||
File f = p.file(p.property('sbomJson'))
|
||||
if (!f.exists()) {
|
||||
throw new GradleException("bom.json 없음: ${f.absolutePath}")
|
||||
}
|
||||
return f
|
||||
}
|
||||
def candidates = [
|
||||
new File(p.buildDir, 'reports/cyclonedx/bom.json'),
|
||||
new File(p.buildDir, 'reports/bom.json'),
|
||||
]
|
||||
File found = candidates.find { it.exists() }
|
||||
if (found == null) {
|
||||
throw new GradleException(
|
||||
"bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') +
|
||||
"\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 배포물에 packaging 되는 jar 목록.
|
||||
* war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를
|
||||
* 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다.
|
||||
*
|
||||
* @return [label: 기준 설명, jars: 행 목록]
|
||||
*/
|
||||
Map collectDeployJars(Project p) {
|
||||
def cfg = p.configurations.findByName('runtimeClasspath')
|
||||
if (cfg == null) {
|
||||
p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다")
|
||||
return [label: '(없음)', jars: []]
|
||||
}
|
||||
|
||||
def warTask = p.tasks.findByName('war')
|
||||
def files
|
||||
String label
|
||||
if (warTask != null) {
|
||||
files = warTask.classpath.files
|
||||
label = 'WAR WEB-INF/lib (war 태스크 classpath)'
|
||||
} else {
|
||||
files = cfg.files
|
||||
label = 'runtimeClasspath (war 태스크 없음)'
|
||||
}
|
||||
|
||||
def coordByFile = [:]
|
||||
cfg.resolvedConfiguration.resolvedArtifacts.each { a ->
|
||||
def id = a.moduleVersion.id
|
||||
coordByFile[a.file] = [group: id.group, name: id.name, version: id.version]
|
||||
}
|
||||
def jars = files.findAll { it.name.endsWith('.jar') }.collect { f ->
|
||||
def c = coordByFile[f]
|
||||
[
|
||||
file : f.name,
|
||||
group : c?.group ?: '',
|
||||
name : c?.name ?: f.name.replaceAll(/\.jar$/, ''),
|
||||
version: c?.version ?: '',
|
||||
coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '',
|
||||
]
|
||||
}.sort { it.file }
|
||||
|
||||
return [label: label, jars: jars]
|
||||
}
|
||||
|
||||
/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */
|
||||
Map indexComponents(bom) {
|
||||
String rootRef = bom.metadata?.component?.'bom-ref'
|
||||
Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set
|
||||
|
||||
def index = [:]
|
||||
bom.components?.each { c ->
|
||||
def hashes = [:]
|
||||
c.hashes?.each { h -> hashes[h.alg] = h.content }
|
||||
|
||||
def licenses = (c.licenses ?: []).collect { l ->
|
||||
l.license?.id ?: l.license?.name ?: l.expression ?: ''
|
||||
}.findAll { it }
|
||||
|
||||
index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [
|
||||
direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이',
|
||||
licenses : licenses.join('; '),
|
||||
licenseList: licenses.isEmpty() ? ['(미상)'] : licenses,
|
||||
purl : c.purl ?: '',
|
||||
sha256 : hashes['SHA-256'] ?: '',
|
||||
sha1 : hashes['SHA-1'] ?: '',
|
||||
description: c.description ?: '',
|
||||
]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */
|
||||
List joinWarRows(List warJars, Map index) {
|
||||
def result = []
|
||||
warJars.eachWithIndex { j, i ->
|
||||
def m = j.coord ? index[j.coord] : null
|
||||
result << [
|
||||
no : i + 1,
|
||||
file : j.file,
|
||||
group : j.group,
|
||||
name : j.name,
|
||||
version : j.version,
|
||||
direct : m?.direct ?: '',
|
||||
licenses : m?.licenses ?: '',
|
||||
licenseList: m?.licenseList ?: ['(미상)'],
|
||||
purl : m?.purl ?: '',
|
||||
sha256 : m?.sha256 ?: '',
|
||||
sha1 : m?.sha1 ?: '',
|
||||
matched : (m != null) ? 'Y' : 'N',
|
||||
description: m?.description ?: '',
|
||||
]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Map createStyles(wb) {
|
||||
def headFont = wb.createFont()
|
||||
headFont.setBold(true)
|
||||
headFont.setColor(IndexedColors.WHITE.getIndex())
|
||||
|
||||
def head = wb.createCellStyle()
|
||||
head.setFont(headFont)
|
||||
head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex())
|
||||
head.setFillPattern(FillPatternType.SOLID_FOREGROUND)
|
||||
head.setAlignment(HorizontalAlignment.CENTER)
|
||||
head.setVerticalAlignment(VerticalAlignment.CENTER)
|
||||
head.setBorderBottom(BorderStyle.THIN)
|
||||
|
||||
def body = wb.createCellStyle()
|
||||
body.setVerticalAlignment(VerticalAlignment.TOP)
|
||||
|
||||
def wrap = wb.createCellStyle()
|
||||
wrap.setVerticalAlignment(VerticalAlignment.TOP)
|
||||
wrap.setWrapText(true)
|
||||
|
||||
def labelFont = wb.createFont()
|
||||
labelFont.setBold(true)
|
||||
def label = wb.createCellStyle()
|
||||
label.setFont(labelFont)
|
||||
|
||||
return [head: head, body: body, wrap: wrap, label: label]
|
||||
}
|
||||
|
||||
/** 헤더 행 생성 + 폭 지정 + 틀고정 */
|
||||
def writeHeader(sheet, style, List<String> headers, List<Integer> widths) {
|
||||
def row = sheet.createRow(0)
|
||||
row.setHeightInPoints(20f)
|
||||
headers.eachWithIndex { h, i ->
|
||||
def cell = row.createCell(i)
|
||||
cell.setCellValue(h)
|
||||
cell.setCellStyle(style)
|
||||
sheet.setColumnWidth(i, widths[i] * 256)
|
||||
}
|
||||
sheet.createFreezePane(0, 1)
|
||||
}
|
||||
|
||||
def cellOf(row, int idx, value, style) {
|
||||
def cell = row.createCell(idx)
|
||||
String s = (value == null) ? '' : value.toString()
|
||||
if (s.length() > SBOM_CELL_LIMIT) {
|
||||
s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)'
|
||||
}
|
||||
cell.setCellValue(s)
|
||||
cell.setCellStyle(style)
|
||||
return cell
|
||||
}
|
||||
|
||||
def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) {
|
||||
def sheet = wb.createSheet('요약')
|
||||
def comp = bom.metadata?.component ?: [:]
|
||||
def tool = bom.metadata?.tools?.components?.getAt(0)
|
||||
Set licenseKinds = warRows.collectMany { it.licenseList } as Set
|
||||
|
||||
def items = [
|
||||
['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"],
|
||||
['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"],
|
||||
['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"],
|
||||
['serialNumber', bom.serialNumber ?: ''],
|
||||
['생성 시각', bom.metadata?.timestamp ?: ''],
|
||||
['생성 도구', tool ? "${tool.name} ${tool.version}" : ''],
|
||||
['원본 파일', src.absolutePath],
|
||||
['배포 수록 jar', warRows.size()],
|
||||
[' └ 직접 의존', warRows.count { it.direct == '직접' }],
|
||||
[' └ 전이 의존', warRows.count { it.direct == '전이' }],
|
||||
[' └ SBOM 미매칭', warRows.count { it.matched == 'N' }],
|
||||
['라이선스 종류', licenseKinds.size()],
|
||||
['라이선스 미상', warRows.count { it.licenses.isEmpty() }],
|
||||
]
|
||||
|
||||
writeHeader(sheet, st.head, ['항목', '값'], [30, 90])
|
||||
items.eachWithIndex { item, i ->
|
||||
def row = sheet.createRow(i + 1)
|
||||
cellOf(row, 0, item[0], st.label)
|
||||
cellOf(row, 1, item[1], st.body)
|
||||
}
|
||||
|
||||
// 라이선스 분포 (요약 하단)
|
||||
def byLicense = [:].withDefault { 0 }
|
||||
warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } }
|
||||
def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) }
|
||||
|
||||
int base = items.size() + 2
|
||||
def hdr = sheet.createRow(base)
|
||||
cellOf(hdr, 0, '라이선스', st.head)
|
||||
cellOf(hdr, 1, 'jar 수', st.head)
|
||||
sorted.eachWithIndex { e, i ->
|
||||
def row = sheet.createRow(base + 1 + i)
|
||||
cellOf(row, 0, e.key, st.body)
|
||||
cellOf(row, 1, e.value, st.body)
|
||||
}
|
||||
}
|
||||
|
||||
/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */
|
||||
def writeWarSheet(wb, st, List warRows) {
|
||||
def sheet = wb.createSheet('WAR 기준')
|
||||
def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분',
|
||||
'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description']
|
||||
def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60]
|
||||
writeHeader(sheet, st.head, headers, widths)
|
||||
|
||||
warRows.eachWithIndex { r, i ->
|
||||
def row = sheet.createRow(i + 1)
|
||||
cellOf(row, 0, r.no, st.body)
|
||||
cellOf(row, 1, r.file, st.body)
|
||||
cellOf(row, 2, r.group, st.body)
|
||||
cellOf(row, 3, r.name, st.body)
|
||||
cellOf(row, 4, r.version, st.body)
|
||||
cellOf(row, 5, r.direct, st.body)
|
||||
cellOf(row, 6, r.licenses, st.body)
|
||||
cellOf(row, 7, r.purl, st.body)
|
||||
cellOf(row, 8, r.sha256, st.body)
|
||||
cellOf(row, 9, r.sha1, st.body)
|
||||
cellOf(row, 10, r.matched, st.body)
|
||||
cellOf(row, 11, r.description, st.wrap)
|
||||
}
|
||||
if (!warRows.isEmpty()) {
|
||||
sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 메뉴 관리 개발 가이드
|
||||
|
||||
포탈 GNB/마이페이지 메뉴는 `menu.yml` → DB(PTL_MENU_*) → 캐시 → 템플릿 렌더 구조로 동작하며,
|
||||
노출/배치 관리는 eapim-admin **포탈메뉴관리**(파트너포탈 > 포탈관리 > 메뉴 관리)에서 수행한다.
|
||||
|
||||
## 구성 요소
|
||||
|
||||
| 구성 | 위치 | 역할 |
|
||||
|---|---|---|
|
||||
| `menu.yml` | `src/main/resources/menu.yml` | 기본 메뉴 정의 (id/노출명/path/권한/기본 배치) |
|
||||
| `roles.yml` | `src/main/resources/roles.yml` | 역할 정의 (`portal.portal_security` 이동분) |
|
||||
| 엔티티/공유 서비스 | `elink-portal-common` `com.eactive.apim.portal.menu.*` | PTL_MENU_ITEM·PTL_MENU_PLACEMENT·PTL_ROLE(+AUTHORITY), `PortalMenuDataService` |
|
||||
| 시더 | `djb/menu/MenuSeeder.java` | 부팅 시 yml→DB 적재 (ApplicationReadyEvent) |
|
||||
| 캐시 | `djb/menu/MenuService.java` | role 비의존 트리 스냅샷, TTL 1시간(PTL_PROPERTY) |
|
||||
| 렌더 | `djb/menu/MenuModelAdvice.java` → 모델 `menuView` | 요청별 노출(EXPOSE_ROLES) 필터 |
|
||||
| 접근 제어 | `djb/menu/MenuAccessInterceptor.java` | ACCESS_ROLES 서버측 집행 (경로 정확 일치) |
|
||||
| 내부 API | `djb/menu/MenuInternalController.java` | `POST /internal/menu/reload` (admin 캐시 리로드 수신) |
|
||||
|
||||
메뉴를 소비하는 템플릿: `fragment/djbank/header_container.html`(데스크톱 nav·마이페이지 드롭다운·모바일 drawer),
|
||||
`fragment/djbank/service_sidebar.html`. 모두 `${menuView}` 를 반복 렌더하므로 **메뉴 추가 시 템플릿 수정 불필요**.
|
||||
|
||||
## menu.yml 스키마
|
||||
|
||||
```yaml
|
||||
portal-menu:
|
||||
items:
|
||||
- id: support # kebab-case 필수 (^[a-z0-9-]+$). 변경 금지(변경=신규 항목)
|
||||
name: "고객지원"
|
||||
group: true # 상위 그룹. path 생략 시 클릭 없음(자식 있어야 노출)
|
||||
section: GNB # GNB(기본) | MYPAGE. 자식은 부모 섹션 상속
|
||||
expose-roles: [] # 생략=전체(익명 포함), AUTHENTICATED=로그인자, 그 외 역할코드 any-of
|
||||
children:
|
||||
- { id: support-faq, name: "FAQ", path: /faq_list }
|
||||
- { id: my-page-webhook, name: "Webhook 관리", path: /webhook, icon: fa-bell,
|
||||
expose-roles: [ROLE_WEBHOOK], access-roles: [ROLE_WEBHOOK] }
|
||||
```
|
||||
|
||||
- `expose-roles` = 메뉴 **노출** 조건, `access-roles` = URL **접근** 조건(인터셉터 차단, redirect).
|
||||
- `icon` 은 마이페이지 드롭다운 전용(FontAwesome 클래스).
|
||||
- 정렬은 yml 나열 순서(기본 배치 sort = index×10).
|
||||
|
||||
## 시딩 규칙 (MenuSeeder)
|
||||
|
||||
1. **항목**: id 기준 upsert. yml 값이 바뀌면 DFLT_*(기본값 스냅샷)를 갱신하고,
|
||||
**관리자가 수정하지 않은 필드(현재값==구 기본값)만** 새 기본값을 따라간다.
|
||||
구조 필드(`group`/`section`/`icon`/`new-window`)는 항상 yml 이 이긴다.
|
||||
2. **배치**: `PTL_MENU_PLACEMENT` 가 **비어있을 때만** 기본 배치로 최초 시딩.
|
||||
이후 배치는 admin 이 소유한다 — 재배포/재기동에도 보존됨.
|
||||
3. yml 에서 항목을 제거해도 DB 는 삭제하지 않고 경고 로그만 남긴다(수동 정리).
|
||||
4. 부팅 시딩 주체는 인증 사용자가 없으므로 `CREATED_BY=SYSTEM`.
|
||||
|
||||
## 캐시와 리로드
|
||||
|
||||
- 스냅샷 TTL: PTL_PROPERTY `Portal / menu.cache.ttl-seconds` (기본 3600초).
|
||||
- 즉시 반영: `curl -X POST http://127.0.0.1:39130/internal/menu/reload`
|
||||
(admin 포탈메뉴관리의 [캐시 Reload] 버튼이 동일 호출 수행).
|
||||
- 내부 API 가드: `Portal / menu.internal.allow-ips` 허용 IP 목록(기본 loopback)
|
||||
+ X-Forwarded-For 동반 요청 거부. CSRF 면제(`/internal/menu/**`).
|
||||
- admin 측 호출 URL: `Portal / portal.internal.menu-reload-url`.
|
||||
|
||||
## 새 메뉴 추가 절차
|
||||
|
||||
**기본 메뉴(코드 배포와 함께)**
|
||||
1. 페이지/라우트 준비 (`portal.pages` 또는 `@GetMapping` — 기존 방식 그대로)
|
||||
2. `menu.yml` 에 항목 추가 (필요 시 breadcrumb 용 `page.home` 트리도 갱신 — 별도 체계 유지)
|
||||
3. 재기동 → 시딩 로그 확인 → 헤더/드로어 노출 확인
|
||||
4. 이미 운영 중인 DB 라면 배치는 자동 추가되지 않음(배치 시딩은 최초 1회) —
|
||||
admin 화면에서 미배치 → 원하는 위치로 드래그 후 저장
|
||||
|
||||
**운영자 임시 메뉴(외부 링크 등)**: admin 포탈메뉴관리 [메뉴 추가] → 미배치 생성 → 드래그 배치 → 저장 → 캐시 Reload.
|
||||
커스텀 항목은 미배치 시 삭제된다.
|
||||
|
||||
## 로컬 개발 주의
|
||||
|
||||
- `gradle bootRun` 으로 시딩까지 확인하려면 damo-manager 가 classpath 에 필요:
|
||||
`JAVA_TOOL_OPTIONS="-Xbootclasspath/a:<...>/apache-tomcat-9.0.115-djb/lib/damo-manager.jar"`
|
||||
(미지정 시 감사 컬럼 암호화 컨버터에서 NoClassDefFoundError).
|
||||
- 템플릿/메뉴 반영 확인은 서버 재시작 후 curl 로.
|
||||
- elink-portal-common 수정 후 Q클래스 duplicate 컴파일 오류 시 각 모듈 `build/generated` 삭제 후 재컴파일.
|
||||
|
||||
## 역할(roles.yml) 변경
|
||||
|
||||
- 로그인 권한 확장은 `PortalRolesProperties`(yml 바인딩)를 직접 사용 — DB 미러(PTL_ROLE*)는
|
||||
admin 권한 선택 체크박스 소스 전용.
|
||||
- 역할 추가 시 `roles.yml` 의 `authority-names` 에 한글 라벨을 함께 등록해야 admin 화면에 표기된다.
|
||||
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
||||
)
|
||||
;
|
||||
|
||||
create table DVPOWN.PT_TOKEN
|
||||
create table PT_TOKEN
|
||||
(
|
||||
(
|
||||
TOKEN VARCHAR2(255) not null
|
||||
primary key,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.eactive.apim.gateway.data.statistics.entity;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* API 상태 모니터링 결과 (AGWAPP.API_STATUS).
|
||||
*
|
||||
* <p>eapim-admin 의 {@code ApiStatusMonitorJob} 이 상태 변화가 있을 때만 upsert 한다.
|
||||
* 포털은 읽기 전용으로 "마지막 상태 변경 시각" 표시에 사용한다.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "API_STATUS")
|
||||
@Data
|
||||
public class GwApiStatus {
|
||||
|
||||
/** EAI 서비스명 */
|
||||
@Id
|
||||
@Column(name = "EAISVCNAME", length = 30)
|
||||
private String eaisvcname;
|
||||
|
||||
/** N 정상 / C 점검 / D 지연 / E 장애 */
|
||||
@Column(name = "STATUS_CODE", length = 1)
|
||||
private String statusCode;
|
||||
|
||||
@Column(name = "MODIFIED_BY", length = 20)
|
||||
private String modifiedBy;
|
||||
|
||||
@Column(name = "MODIFIED_DATE")
|
||||
private LocalDateTime modifiedDate;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.eactive.apim.gateway.data.statistics.repository;
|
||||
|
||||
import com.eactive.apim.gateway.data.statistics.entity.GwApiStatus;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface GwApiStatusRepository extends Repository<GwApiStatus, String> {
|
||||
|
||||
/**
|
||||
* API 상태가 마지막으로 변경된 시각. 데이터가 없으면 empty.
|
||||
*/
|
||||
@Query("SELECT MAX(s.modifiedDate) FROM GwApiStatus s")
|
||||
Optional<LocalDateTime> findLastModifiedDate();
|
||||
}
|
||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
||||
|
||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||
|
||||
public PortalApplication() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
portal_logger.info("##### PortalApplication Start #####");
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -30,6 +32,7 @@ public class ApiController {
|
||||
private final ApiService apiService;
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final ApiSearchFacade apiSearchFacade;
|
||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_TOKEN_API_NAME = "인증";
|
||||
|
||||
@@ -38,7 +41,24 @@ public class ApiController {
|
||||
if (id == null) {
|
||||
return "redirect:/apis/common";
|
||||
}
|
||||
populateDetailModel(id, model);
|
||||
model.addAttribute("activeTab", "api-info");
|
||||
return "apps/apis/mainApiDetail";
|
||||
}
|
||||
|
||||
// 테스트베드를 API 정보와 별도 URL로 분리(딥링크·북마크 가능). 미인증 사용자도 페이지 진입은
|
||||
// 허용하되, 실제 테스트베드(Swagger)는 인증 사용자에게만 렌더하고 미인증에는 로그인 안내를 노출한다.
|
||||
@GetMapping("/detail/testbed")
|
||||
public String apidetailTestbed(@RequestParam(value = "id", required = false) String id, ModelMap model) {
|
||||
if (id == null) {
|
||||
return "redirect:/apis/common";
|
||||
}
|
||||
populateDetailModel(id, model);
|
||||
model.addAttribute("activeTab", "testbed");
|
||||
return "apps/apis/mainApiDetail";
|
||||
}
|
||||
|
||||
private void populateDetailModel(String id, ModelMap model) {
|
||||
ApiSpecInfoDto api = apiService.selectDetail(id);
|
||||
if (api == null) {
|
||||
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
||||
@@ -46,10 +66,16 @@ public class ApiController {
|
||||
|
||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
|
||||
|
||||
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
|
||||
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
|
||||
if (apiGroup != null) {
|
||||
api.setApiGroupName(apiGroup.getGroupName());
|
||||
}
|
||||
|
||||
model.addAttribute("apiSpecInfo", api);
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
return "apps/apis/mainApiDetail";
|
||||
model.addAttribute("authenticated", SecurityUtil.isAuthenticated());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -62,12 +88,18 @@ public class ApiController {
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||
// 카드의 현재 상태 태그 노출 여부 (PTL_PROPERTY djb.apistatus.api-list-status-badge)
|
||||
model.addAttribute("apiStatusBadgeEnabled", apiStatusCatalogService.isApiListStatusBadgeEnabled());
|
||||
|
||||
return "apps/apis/mainApiList";
|
||||
}
|
||||
|
||||
@GetMapping("/testbed/api")
|
||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
String selectedServiceId = "";
|
||||
boolean idExists = false;
|
||||
@@ -93,6 +125,10 @@ public class ApiController {
|
||||
|
||||
@GetMapping("/testbed")
|
||||
public String testbedByApiService(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
boolean idExists = false;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
@@ -37,13 +38,18 @@ public class APISender {
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
|
||||
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||
}
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
|
||||
String response = getResponse(connection);
|
||||
|
||||
connection.disconnect();
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -85,11 +91,15 @@ public class APISender {
|
||||
}
|
||||
connection.setDoOutput(true);
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
|
||||
}
|
||||
|
||||
String response = getResponse(connection);
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -110,6 +120,12 @@ public class APISender {
|
||||
}
|
||||
connection.setDoOutput(true);
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
|
||||
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
|
||||
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
|
||||
}
|
||||
|
||||
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
|
||||
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
|
||||
outputStream.write(requestBodyBytes);
|
||||
@@ -120,7 +136,7 @@ public class APISender {
|
||||
connection.disconnect();
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug(response);
|
||||
logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
/**
|
||||
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
|
||||
*
|
||||
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
|
||||
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
|
||||
*
|
||||
* <p>마스킹 정책:</p>
|
||||
* <ul>
|
||||
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
|
||||
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
|
||||
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class ApiTesterAuditLogger {
|
||||
|
||||
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/** 값 전체를 마스킹할 키(소문자 비교) */
|
||||
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
|
||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
|
||||
|
||||
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
|
||||
private static final String[] AUDIT_HEADERS = {
|
||||
"content-type", "accept", "referer", "origin", "x-forwarded-for",
|
||||
"original-api-id", "authorization"};
|
||||
|
||||
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
|
||||
private static final int JSON_LOG_MAX_LENGTH = 2000;
|
||||
|
||||
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
|
||||
private static final int NON_JSON_PREVIEW_LENGTH = 8;
|
||||
|
||||
private ApiTesterAuditLogger() {
|
||||
}
|
||||
|
||||
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
|
||||
public static String newAuditId() {
|
||||
return UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
|
||||
*
|
||||
* @param targetUrl original-url 헤더 값 (없으면 null)
|
||||
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
|
||||
* @param tokenRequest OAuth 토큰 발급 요청 여부
|
||||
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
|
||||
*/
|
||||
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
|
||||
String gatewayMode, boolean tokenRequest, String body) {
|
||||
try {
|
||||
StringBuilder sb = new StringBuilder(256);
|
||||
sb.append("REQ [").append(auditId).append(']');
|
||||
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
|
||||
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
|
||||
sb.append(" user=").append(currentUser());
|
||||
sb.append(" method=").append(request.getMethod());
|
||||
sb.append(" mode=").append(gatewayMode);
|
||||
sb.append(" token=").append(tokenRequest);
|
||||
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
|
||||
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
|
||||
sb.append(" headers=").append(buildHeaderSummary(request));
|
||||
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
|
||||
auditLogger.info(sb.toString());
|
||||
} catch (Exception e) {
|
||||
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
|
||||
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
|
||||
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 요청 정보 구성
|
||||
// =========================================================================
|
||||
|
||||
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
|
||||
private static String currentUser() {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
|
||||
return "anonymous";
|
||||
}
|
||||
String name = auth.getName();
|
||||
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
|
||||
private static String buildHeaderSummary(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (String name : AUDIT_HEADERS) {
|
||||
String value = request.getHeader(name);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (!first) {
|
||||
sb.append(", ");
|
||||
}
|
||||
first = false;
|
||||
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
|
||||
}
|
||||
return sb.append('}').toString();
|
||||
}
|
||||
|
||||
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
|
||||
private static String maskHeaderValue(String name, String value) {
|
||||
if (!SECRET_KEYS.contains(name.toLowerCase())) {
|
||||
return value;
|
||||
}
|
||||
int space = value.indexOf(' ');
|
||||
if (space > 0) {
|
||||
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
|
||||
}
|
||||
return partialMask(value);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 본문 마스킹
|
||||
// =========================================================================
|
||||
|
||||
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
|
||||
if (body == null || body.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
|
||||
if (tokenRequest) {
|
||||
return "\"" + maskFormBody(body) + "\"";
|
||||
}
|
||||
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
|
||||
if (contentType != null && contentType.toLowerCase().contains("json")) {
|
||||
String maskedJson = tryMaskJson(body);
|
||||
if (maskedJson != null) {
|
||||
return maskedJson;
|
||||
}
|
||||
}
|
||||
return nonJsonSummary(body);
|
||||
}
|
||||
|
||||
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
|
||||
private static String maskFormBody(String body) {
|
||||
StringBuilder sb = new StringBuilder(body.length());
|
||||
String[] pairs = body.split("&");
|
||||
for (int i = 0; i < pairs.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
int eq = pairs[i].indexOf('=');
|
||||
if (eq < 0) {
|
||||
sb.append(partialMask(pairs[i]));
|
||||
continue;
|
||||
}
|
||||
String key = pairs[i].substring(0, eq);
|
||||
String value = pairs[i].substring(eq + 1);
|
||||
sb.append(key).append('=');
|
||||
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
|
||||
}
|
||||
return sanitize(sb.toString());
|
||||
}
|
||||
|
||||
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
|
||||
private static String maskQueryValues(String url) {
|
||||
int qs = url.indexOf('?');
|
||||
if (qs < 0) {
|
||||
return url;
|
||||
}
|
||||
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
|
||||
}
|
||||
|
||||
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
|
||||
private static String tryMaskJson(String body) {
|
||||
try {
|
||||
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
|
||||
String out = OBJECT_MAPPER.writeValueAsString(masked);
|
||||
if (out.length() > JSON_LOG_MAX_LENGTH) {
|
||||
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
|
||||
}
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
|
||||
private static JsonNode maskJsonNode(JsonNode node) {
|
||||
if (node.isObject()) {
|
||||
ObjectNode obj = (ObjectNode) node;
|
||||
Iterator<String> names = obj.fieldNames();
|
||||
Set<String> fieldNames = new HashSet<>();
|
||||
while (names.hasNext()) {
|
||||
fieldNames.add(names.next());
|
||||
}
|
||||
for (String field : fieldNames) {
|
||||
if (SECRET_KEYS.contains(field.toLowerCase())) {
|
||||
obj.set(field, TextNode.valueOf("*****"));
|
||||
} else {
|
||||
obj.set(field, maskJsonNode(obj.get(field)));
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
ArrayNode arr = (ArrayNode) node;
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
arr.set(i, maskJsonNode(arr.get(i)));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
if (node.isNull() || node.isMissingNode()) {
|
||||
return node;
|
||||
}
|
||||
return TextNode.valueOf(partialMask(node.asText()));
|
||||
}
|
||||
|
||||
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
|
||||
private static String nonJsonSummary(String body) {
|
||||
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
|
||||
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
|
||||
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
|
||||
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 공통 helper
|
||||
// =========================================================================
|
||||
|
||||
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
|
||||
private static String partialMask(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int len = value.length();
|
||||
if (len <= 2) {
|
||||
return stars(len);
|
||||
}
|
||||
int visible = Math.min(4, Math.max(1, len / 3));
|
||||
return value.substring(0, visible) + "***";
|
||||
}
|
||||
|
||||
private static String stars(int count) {
|
||||
char[] arr = new char[count];
|
||||
Arrays.fill(arr, '*');
|
||||
return new String(arr);
|
||||
}
|
||||
|
||||
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
|
||||
private static String sanitize(String value) {
|
||||
if (value == null) {
|
||||
return "-";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '"') {
|
||||
sb.append('\'');
|
||||
} else if (c == '\r' || c == '\n' || c == '\t') {
|
||||
sb.append(' ');
|
||||
} else if (c < 0x20) {
|
||||
sb.append('?');
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ package com.eactive.apim.portal.apps.apis.filter;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -68,8 +69,18 @@ public class ApiTesterFilter implements Filter {
|
||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||
String url = httpServletRequest.getHeader("original-url");
|
||||
|
||||
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
|
||||
String auditId = ApiTesterAuditLogger.newAuditId();
|
||||
long auditStart = System.currentTimeMillis();
|
||||
String auditType = "-";
|
||||
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
|
||||
String proxyTarget = null;
|
||||
|
||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
|
||||
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
|
||||
System.currentTimeMillis() - auditStart);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
@@ -78,23 +89,24 @@ public class ApiTesterFilter implements Filter {
|
||||
// 반환하기 위해 try 로 감싼다.
|
||||
try {
|
||||
|
||||
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
|
||||
// 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
|
||||
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
|
||||
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
|
||||
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
|
||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||
|| url.contains(gatewayProperty.tokenPath());
|
||||
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
|
||||
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||
String requestBody = readBody(httpServletRequest);
|
||||
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
|
||||
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
|
||||
|
||||
if (tokenRequest) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String body = sb.toString();
|
||||
String body = requestBody;
|
||||
|
||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||
if (mockTokenRequest) {
|
||||
auditType = "TOKEN_MOCK";
|
||||
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
@@ -106,7 +118,7 @@ public class ApiTesterFilter implements Filter {
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
@@ -117,11 +129,18 @@ public class ApiTesterFilter implements Filter {
|
||||
response.getWriter().println(token);
|
||||
} else {
|
||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||
auditType = "TOKEN_GW";
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
headers.put("Accept", "application/json");
|
||||
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||
proxyTarget = target;
|
||||
if (logger.isDebugEnabled()) {
|
||||
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
|
||||
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
|
||||
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
|
||||
}
|
||||
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
response.setContentType("application/json");
|
||||
@@ -133,6 +152,7 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
auditType = "SPEC_NOT_FOUND";
|
||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||
return;
|
||||
@@ -142,6 +162,7 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||
auditType = "SAMPLE";
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||
return;
|
||||
@@ -151,6 +172,7 @@ public class ApiTesterFilter implements Filter {
|
||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||
auditType = gw ? "GW" : "MOCK";
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
@@ -160,6 +182,10 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
|
||||
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
|
||||
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
|
||||
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
|
||||
|
||||
String targetUri;
|
||||
Map<String, String[]> paramMap;
|
||||
@@ -176,36 +202,66 @@ public class ApiTesterFilter implements Filter {
|
||||
paramMap = extractQueryParams(url);
|
||||
}
|
||||
|
||||
proxyTarget = targetUri;
|
||||
if (logger.isDebugEnabled()) {
|
||||
// GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
|
||||
// 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
|
||||
logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
|
||||
auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
|
||||
apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
|
||||
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
|
||||
}
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
|
||||
auditType, auditId, targetUri,
|
||||
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
|
||||
logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
|
||||
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (IOException e) {
|
||||
// 연결 실패 등 네트워크 오류
|
||||
logger.error("테스트베드 프록시 호출 실패", e);
|
||||
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
|
||||
logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
|
||||
} catch (Exception e) {
|
||||
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||
logger.error("테스트베드 프록시 처리 오류", e);
|
||||
logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
|
||||
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
|
||||
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} finally {
|
||||
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
|
||||
System.currentTimeMillis() - auditStart);
|
||||
}
|
||||
}
|
||||
|
||||
/** 요청 본문 전체를 문자열로 읽는다. */
|
||||
/**
|
||||
* 요청 본문 전체를 문자열로 읽는다.
|
||||
*
|
||||
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
|
||||
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
|
||||
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
|
||||
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
|
||||
*/
|
||||
private String readBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = request.getReader();
|
||||
@@ -213,6 +269,39 @@ public class ApiTesterFilter implements Filter {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
if (sb.length() == 0 && isFormUrlEncoded(request)) {
|
||||
String rebuilt = rebuildFormBodyFromParams(request);
|
||||
if (!rebuilt.isEmpty()) {
|
||||
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
|
||||
return rebuilt;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
|
||||
private boolean isFormUrlEncoded(HttpServletRequest request) {
|
||||
String contentType = request.getContentType();
|
||||
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
|
||||
private String rebuildFormBodyFromParams(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
for (String value : entry.getValue()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
try {
|
||||
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
|
||||
.append('=')
|
||||
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@@ -232,6 +321,27 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
|
||||
/** 상태코드 + JSON 본문 응답. */
|
||||
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
|
||||
private String maskHeaders(Map<String, String> headers) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||
if (sb.length() > 1) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
|
||||
}
|
||||
return sb.append('}').toString();
|
||||
}
|
||||
|
||||
/** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
|
||||
private String previewOf(String body) {
|
||||
if (body == null) {
|
||||
return "null";
|
||||
}
|
||||
String flat = body.replaceAll("\\s+", " ").trim();
|
||||
return flat.length() > 300 ? flat.substring(0, 300) + "…" : flat;
|
||||
}
|
||||
|
||||
private void writeJson(ServletResponse response, int status, String json) throws IOException {
|
||||
((HttpServletResponse) response).setStatus(status);
|
||||
response.setContentType("application/json");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.app.controller;
|
||||
|
||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
@@ -10,8 +11,11 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
||||
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
||||
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
@@ -48,7 +52,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/myapikey")
|
||||
@RequestMapping("/clients")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
|
||||
public class MyAppController {
|
||||
@@ -80,7 +84,8 @@ public class MyAppController {
|
||||
private final ApiService apiService;
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileTypeDetector fileTypeDetector;
|
||||
private final AdminGatewayClient adminGatewayClient;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||
|
||||
@@ -112,14 +117,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
|
||||
|
||||
if (appRequest == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// API 목록 조회 및 설정
|
||||
@@ -150,14 +155,14 @@ public class MyAppController {
|
||||
@Secured("ROLE_APP")
|
||||
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
|
||||
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// API 목록에 서비스 정보 추가
|
||||
@@ -178,7 +183,7 @@ public class MyAppController {
|
||||
|
||||
model.addAttribute("apiKey", apiKey);
|
||||
model.addAttribute("secretAvailable", secretAvailable);
|
||||
model.addAttribute("authType", "OAuth2");
|
||||
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
|
||||
|
||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||
}
|
||||
@@ -217,19 +222,29 @@ public class MyAppController {
|
||||
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
||||
result.put("success", true);
|
||||
result.put("message", "신청이 취소되었습니다.");
|
||||
} catch (Exception e) {
|
||||
} catch (InvalidApprovalTransitionException e) {
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage());
|
||||
result.put("message", "내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
|
||||
} catch (IllegalStateException e) {
|
||||
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("API Key 신청 취소 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key를 삭제합니다.
|
||||
* AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환
|
||||
* API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성)
|
||||
* 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와
|
||||
* PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다.
|
||||
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다.
|
||||
*
|
||||
* @param requestData 요청 데이터 (clientId와 type 포함)
|
||||
* @param requestData 요청 데이터 (clientId, reason)
|
||||
* @return 성공/실패 결과를 담은 Map
|
||||
*/
|
||||
@PostMapping("/api_key_delete")
|
||||
@@ -245,38 +260,44 @@ public class MyAppController {
|
||||
return result;
|
||||
}
|
||||
|
||||
String reason = requestData.get("reason");
|
||||
if (reason == null || reason.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해지 사유를 입력해 주세요.");
|
||||
return result;
|
||||
}
|
||||
if (reason.length() > 1000) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해지 사유는 1000자 이내로 입력해 주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
String orgId = user.getPortalOrg().getId();
|
||||
|
||||
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
|
||||
// 1. 소유권 확인 (다른 조직의 인증키 해지 방지)
|
||||
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
|
||||
// 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리)
|
||||
try {
|
||||
adminGatewayClient.blockClient(clientId);
|
||||
} catch (Exception e) {
|
||||
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
|
||||
appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg());
|
||||
} catch (IllegalStateException e) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
result.put("msg", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. GW 차단 성공 시에만 포털 credential 삭제
|
||||
try {
|
||||
appServiceFacade.deleteApp(orgId, clientId);
|
||||
} catch (Exception e) {
|
||||
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
|
||||
log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e);
|
||||
result.put("success", false);
|
||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
||||
result.put("msg", UserErrorMessageResolver.resolveAsHtml(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
result.put("msg", "API Key가 삭제되었습니다.");
|
||||
result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -310,10 +331,12 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||
* Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
|
||||
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
|
||||
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
|
||||
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
|
||||
*
|
||||
* @param requestData clientId, password 포함
|
||||
* @param requestData clientId 포함
|
||||
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
|
||||
*/
|
||||
@PostMapping("/credential/reveal-secret")
|
||||
@@ -323,7 +346,6 @@ public class MyAppController {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
String clientId = requestData.get("clientId");
|
||||
String password = requestData.get("password");
|
||||
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
@@ -333,14 +355,7 @@ public class MyAppController {
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
// 1. 본인 확인 (비밀번호)
|
||||
if (!appServiceFacade.verifyUserPassword(user, password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
|
||||
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
|
||||
try {
|
||||
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
|
||||
if (secret == null) {
|
||||
@@ -422,7 +437,7 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST_VIEW")
|
||||
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
if (id == null) {
|
||||
return new ModelAndView("redirect:/myapikey/api_key_request/history");
|
||||
return new ModelAndView("redirect:/clients/api_key_request/history");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -520,7 +535,7 @@ public class MyAppController {
|
||||
registration.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
return new ModelAndView("redirect:/clients/register/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -585,7 +600,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
|
||||
@@ -616,7 +631,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 1단계로 리다이렉트
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -634,22 +649,16 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!registration.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
registration.setSelectedApis(selectedApis);
|
||||
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new ArrayList<>());
|
||||
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!registration.isComplete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
return new ModelAndView("redirect:/clients/register/step1");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -666,12 +675,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/register/step3");
|
||||
return new ModelAndView("redirect:/clients/register/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
return new ModelAndView("redirect:/clients/register/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +698,7 @@ public class MyAppController {
|
||||
|
||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||
if (registrationSuccess == null || !registrationSuccess) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 결과 페이지 표시용 속성 설정
|
||||
@@ -708,7 +717,7 @@ public class MyAppController {
|
||||
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||
// 등록과 관련된 세션 데이터 초기화
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/myapikey";
|
||||
return "redirect:/clients";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,13 +761,15 @@ public class MyAppController {
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
public ModelAndView modifyStep1(
|
||||
@RequestParam(value = "clientId", required = false) String clientId,
|
||||
@RequestParam(value = "goto", required = false) String gotoStep,
|
||||
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
|
||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
Model model,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (clientId == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -766,7 +777,7 @@ public class MyAppController {
|
||||
// 기존 API Key 정보 조회
|
||||
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
|
||||
if (apiKey == null) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 새로운 수정 세션 시작시에만 초기화
|
||||
@@ -808,6 +819,16 @@ public class MyAppController {
|
||||
model.addAttribute("apiKeyModification", modification);
|
||||
}
|
||||
|
||||
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
|
||||
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
|
||||
if ("apis".equals(gotoStep)) {
|
||||
String redirectUrl = "redirect:/clients/modify/step2";
|
||||
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
|
||||
redirectUrl += "?apiApplyToast=" + apiApplyToast;
|
||||
}
|
||||
return new ModelAndView(redirectUrl);
|
||||
}
|
||||
|
||||
// Step 모델 설정
|
||||
setupStepModel(model, 1);
|
||||
model.addAttribute("userOrg", user.getPortalOrg());
|
||||
@@ -859,7 +880,7 @@ public class MyAppController {
|
||||
modification.setIpWhitelistFromString(ipWhitelist);
|
||||
}
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
|
||||
} catch (Exception e) {
|
||||
setupStepModel(model, 1);
|
||||
@@ -881,7 +902,7 @@ public class MyAppController {
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// 서비스 카테고리와 API 목록 가져오기
|
||||
@@ -891,6 +912,8 @@ public class MyAppController {
|
||||
setupStepModel(model, 2);
|
||||
model.addAttribute("apiServices", apiServices);
|
||||
model.addAttribute("modification", modification);
|
||||
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
|
||||
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
|
||||
|
||||
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
||||
}
|
||||
@@ -912,7 +935,7 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
// 1단계로 리다이렉트
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -925,18 +948,19 @@ public class MyAppController {
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
HttpSession session,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
// 1단계가 완료되었는지 검증
|
||||
if (!modification.isStep1Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
@@ -945,7 +969,15 @@ public class MyAppController {
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!modification.isComplete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
|
||||
}
|
||||
|
||||
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
|
||||
if (isAppModifyTwofaRequired()
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
|
||||
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
@@ -962,12 +994,12 @@ public class MyAppController {
|
||||
// 성공적으로 완료된 후 세션 초기화
|
||||
sessionStatus.setComplete();
|
||||
|
||||
return new ModelAndView("redirect:/myapikey/modify/step3");
|
||||
return new ModelAndView("redirect:/clients/modify/step3");
|
||||
|
||||
} catch (Exception e) {
|
||||
// 실패 시 에러 메시지와 함께 step2로 돌아감
|
||||
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||
return new ModelAndView("redirect:/clients/modify/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,7 +1016,7 @@ public class MyAppController {
|
||||
|
||||
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
|
||||
if (modificationComplete == null || !modificationComplete) {
|
||||
return new ModelAndView("redirect:/myapikey");
|
||||
return new ModelAndView("redirect:/clients");
|
||||
}
|
||||
|
||||
// 결과 페이지 표시용 속성 설정
|
||||
@@ -1013,12 +1045,18 @@ public class MyAppController {
|
||||
|
||||
// clientId가 있으면 상세 페이지로, 없으면 목록으로
|
||||
if (clientId != null && !clientId.isEmpty()) {
|
||||
return "redirect:/myapikey/credential_detail?id=" + clientId;
|
||||
return "redirect:/clients/credential_detail?id=" + clientId;
|
||||
} else {
|
||||
return "redirect:/myapikey";
|
||||
return "redirect:/clients";
|
||||
}
|
||||
}
|
||||
|
||||
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
|
||||
private boolean isAppModifyTwofaRequired() {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ public class ApiKeyRegistrationDTO implements Serializable {
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return isStep1Complete() && isStep2Complete();
|
||||
// API 선택은 선택 사항이므로 기본 정보(Step1)만 완료되면 등록 가능하다.
|
||||
return isStep1Complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -25,8 +24,7 @@ public class AppRequestDTO {
|
||||
|
||||
private ApprovalDTO approval;
|
||||
|
||||
@NotEmpty
|
||||
private String apiList = ""; //comma separated api id list
|
||||
private String apiList = ""; //comma separated api id list (미선택 허용)
|
||||
|
||||
private String apiGroupList = ""; //comma separated api group id list
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -59,21 +60,47 @@ public class AppServiceFacade {
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AdminGatewayClient adminGatewayClient;
|
||||
|
||||
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
||||
|
||||
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
|
||||
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
|
||||
// 최근 수정(발급/변경) 순으로 정렬. 수정일 없는 건은 뒤로.
|
||||
return clients.stream()
|
||||
.sorted(Comparator.comparing(Credential::getModifiedon,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.map(credentialMapper::toVo).collect(Collectors.toList());
|
||||
|
||||
}
|
||||
|
||||
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
|
||||
List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출` 한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
|
||||
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
|
||||
appRequests.sort(Comparator.comparingInt(this::pendingStatusRank)
|
||||
.thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
|
||||
return appRequests;
|
||||
}
|
||||
|
||||
private int pendingStatusRank(AppRequest request) {
|
||||
if (request.getApproval() == null) {
|
||||
return 3;
|
||||
}
|
||||
if (request.getApproval().getApprovalStatus() instanceof ProcessingState) {
|
||||
return 1;
|
||||
}
|
||||
if (request.getApproval().getApprovalStatus() instanceof RequestedState) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
public ClientDTO getApiKey(String orgid, String clientId) {
|
||||
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
|
||||
}
|
||||
@@ -133,8 +160,81 @@ public class AppServiceFacade {
|
||||
approvalService.beginApproval(approvalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 이용 해지(DELETE) 결재 신청을 생성하고 결재를 개시합니다.
|
||||
* GW 차단/삭제와 PTL_CREDENTIAL 삭제는 여기서 하지 않으며,
|
||||
* eapim-admin 승인 시점에 PortalAppApprovalListener 가 수행합니다.
|
||||
*
|
||||
* @throws IllegalStateException 중복 신청, 변경 신청 진행 중, 승인라인 미등록 등 사용자에게 안내할 상황
|
||||
*/
|
||||
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
|
||||
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
continue;
|
||||
}
|
||||
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
|
||||
if (!inProgress) {
|
||||
continue;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(r.getType())) {
|
||||
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
|
||||
}
|
||||
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
|
||||
AppRequestDTO dto = new AppRequestDTO();
|
||||
dto.setType(AppRequestType.DELETE);
|
||||
dto.setClientId(clientId);
|
||||
dto.setReason(reason);
|
||||
dto.setOrg(portalOrgMapper.toVo(portalOrg));
|
||||
|
||||
AppRequestDTO saved = createAppRequest(dto);
|
||||
|
||||
// 3. 승인라인 미등록이면 approval 이 null — 결재 없는 해지 신청은 만들지 않는다(트랜잭션 롤백)
|
||||
if (saved.getApproval() == null || saved.getApproval().getId() == null) {
|
||||
throw new IllegalStateException("APP 승인라인이 등록되어 있지 않아 해지를 신청할 수 없습니다. 관리자에게 문의해 주세요.");
|
||||
}
|
||||
|
||||
beginApproval(saved.getApproval().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
|
||||
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
|
||||
*/
|
||||
public boolean hasPendingDeleteRequest(String clientId) {
|
||||
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.DELETE)).stream()
|
||||
.anyMatch(r -> r.getApproval() != null
|
||||
&& (r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState));
|
||||
}
|
||||
|
||||
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
|
||||
if (request.getApproval() == null) {
|
||||
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
|
||||
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
|
||||
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
|
||||
// DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다.
|
||||
if (StringUtils.isNotBlank(request.getClientId())
|
||||
&& !AppRequestType.DELETE.equals(request.getType())) {
|
||||
try {
|
||||
adminGatewayClient.blockClient(request.getClientId());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
|
||||
}
|
||||
}
|
||||
appRequestRepository.delete(request);
|
||||
} else {
|
||||
approvalService.cancelAppApproval(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -164,9 +264,15 @@ public class AppServiceFacade {
|
||||
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
||||
|
||||
for (String apiId : apiList) {
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
|
||||
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
||||
spec.setService(serviceDTO.getGroupName());
|
||||
if (spec == null) {
|
||||
continue;
|
||||
}
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
if (serviceDTO != null) {
|
||||
spec.setService(serviceDTO.getGroupName());
|
||||
}
|
||||
appRequest.getApiSpecList().add(spec);
|
||||
}
|
||||
}
|
||||
@@ -316,18 +422,4 @@ public class AppServiceFacade {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key(Credential)를 즉시 삭제합니다.
|
||||
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
||||
*
|
||||
* @param orgId 조직 ID
|
||||
* @param clientId 삭제할 클라이언트 ID
|
||||
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
|
||||
*/
|
||||
public void deleteApp(String orgId, String clientId) {
|
||||
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
|
||||
|
||||
credentialRepository.delete(credential);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.eactive.apim.portal.apps.auth;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 인증(이메일/SMS) 테스트 안내 관련 PTL_PROPERTY 접근 래퍼.
|
||||
*
|
||||
* <p>그룹 {@code Portal}, 키 {@code auth.test-notice.enabled}(true/false). 값이 참이면 인증 요청
|
||||
* 응답에 인증번호를 실어 화면에 노출한다(실제 발송 대신 테스트 확인 용도). 기존 application.yml
|
||||
* {@code portal.test-auth-notice-enabled} 설정을 DB PTL_PROPERTY 로 이전한 것으로,
|
||||
* {@link TwoFactorProperties} 와 동일한 {@code getOrCreateProperty} 패턴을 따른다.</p>
|
||||
*
|
||||
* <p><b>prod 프로파일에서는 DB 값과 무관하게 항상 false</b> 를 반환한다(운영 환경 인증번호 노출 금지).
|
||||
* 세션 keepalive 등 다른 비운영 전용 스위치와 동일한 정책이다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthNoticeProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "auth.test-notice.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final Environment environment;
|
||||
|
||||
/**
|
||||
* 인증 요청 응답에 인증번호를 실어 UI 에 노출할지 여부(개발/테스트 전용).
|
||||
* prod 환경에서는 property 값과 무관하게 항상 false.
|
||||
*/
|
||||
public boolean isTestNoticeEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return false;
|
||||
}
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, KEY_TEST_NOTICE_ENABLED, "true",
|
||||
"인증(이메일/SMS) 요청 시 인증번호를 화면에 표시할지 여부 (true/false, 테스트 전용)");
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,11 @@ public interface AuthNumberService {
|
||||
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||
|
||||
/**
|
||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||
|
||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||
logger.info("Sending auth number to: {} via {}", recipientKey, msgType);
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||
|
||||
validateResendTime(recipientKey);
|
||||
|
||||
@@ -55,7 +61,7 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||
|
||||
storage.saveAuthNumber(recipientKey, authNumber,
|
||||
LocalDateTime.now().plusSeconds(authNumberExpirationTime));
|
||||
LocalDateTime.now().plusSeconds(ttlSeconds));
|
||||
|
||||
return authNumber;
|
||||
}
|
||||
@@ -66,17 +72,20 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
logger.info("Verifying auth number for: {}", recipientKey);
|
||||
|
||||
TwoFactorAuth storedAuth = storage.getAuthNumber(recipientKey)
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요."));
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.NOT_FOUND));
|
||||
|
||||
if (storedAuth.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
storage.deleteAuthNumber(recipientKey);
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.EXPIRED);
|
||||
}
|
||||
|
||||
if (authNumber.equals(storedAuth.getAuthNumber())) {
|
||||
return true;
|
||||
} else {
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.");
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.",
|
||||
AuthNumberException.Reason.MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* step-up 2FA(민감기능 추가 인증) 진입 가드.
|
||||
*
|
||||
* <p>{@link StepUpProtectedPaths#isInterceptorGuarded(String)} 경로 진입 시, 유효한 1회용
|
||||
* 통과권이 없으면 2FA 를 요구한다.
|
||||
* <ul>
|
||||
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
|
||||
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
|
||||
* </ul>
|
||||
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
|
||||
*
|
||||
* <p>내 정보 변경({@code /mypage}, PASSWORD 레벨)과 비밀번호 반영({@code POST /password/change},
|
||||
* 반영 직전 2FA)은 각 컨트롤러가 직접 관장하므로 이 인터셉터 대상이 아니다.</p>
|
||||
*/
|
||||
public class StepUpAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
public StepUpAuthInterceptor(TwoFactorService twoFactorService, TwoFactorProperties twoFactorProperties) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
if (!twoFactorProperties.isStepUpEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (!StepUpProtectedPaths.isInterceptorGuarded(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
|
||||
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
|
||||
return true;
|
||||
}
|
||||
|
||||
// 1회용 통과권 소비 시도 (매번 인증: 있으면 소멸 후 통과)
|
||||
if (twoFactorService.consumeStepUpPass(session, path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("POST".equalsIgnoreCase(request.getMethod())) {
|
||||
// AJAX 지점(Secret 조회/앱 해지) → 프론트가 팝업을 띄우도록 신호
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"stepUpRequired\":true}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// GET 페이지 진입 → 챌린지 페이지로 유도(원경로+쿼리 보존)
|
||||
String returnUrl = path;
|
||||
String query = request.getQueryString();
|
||||
if (query != null && !query.isEmpty()) {
|
||||
returnUrl = returnUrl + "?" + query;
|
||||
}
|
||||
String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8.name());
|
||||
response.sendRedirect(request.getContextPath() + "/auth/2fa/challenge?returnUrl=" + encoded);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
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 javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
* 완화된 step-up(PASSWORD 레벨) 확인 페이지.
|
||||
*
|
||||
* <p>{@link StepUpProtectedPaths#isPasswordGated(String)} 경로(예: {@code /mypage})는 2FA 대신
|
||||
* <b>현재 비밀번호 재확인</b>만 요구한다. 각 컨트롤러가 통과권이 없을 때 이 페이지로 유도하고,
|
||||
* 확인 성공 시 해당 경로의 통과권을 발급한 뒤 원경로로 복귀시킨다.</p>
|
||||
*
|
||||
* <p>returnUrl 은 PASSWORD 레벨 화이트리스트로만 검증·복귀하여 open redirect 를 막는다.</p>
|
||||
*/
|
||||
@Controller
|
||||
@Secured("ROLE_ACCOUNT")
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/auth/stepup")
|
||||
public class StepUpPasswordController {
|
||||
|
||||
private final UserFacade userFacade;
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
@GetMapping("/password")
|
||||
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
String path = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("returnUrl", path);
|
||||
return "apps/auth/stepupPassword";
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public String verify(@RequestParam String currentPassword,
|
||||
@RequestParam(required = false) String returnUrl,
|
||||
HttpSession session, Model model) {
|
||||
String path = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isPasswordGated(path)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
String loginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||
twoFactorService.grantStepUpPass(session, path);
|
||||
return "redirect:" + path;
|
||||
}
|
||||
|
||||
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
model.addAttribute("returnUrl", path);
|
||||
return "apps/auth/stepupPassword";
|
||||
}
|
||||
|
||||
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
|
||||
private static String pathOf(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int q = url.indexOf('?');
|
||||
return q >= 0 ? url.substring(0, q) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* step-up(민감기능 추가 인증) 대상 서블릿 경로 화이트리스트 + 지점별 프로퍼티 키 + 검증 레벨.
|
||||
*
|
||||
* <p>검증 레벨(완화 정책)</p>
|
||||
* <ul>
|
||||
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
|
||||
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
|
||||
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
||||
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>세 가지 관심사를 분리한다.</p>
|
||||
* <ol>
|
||||
* <li>{@link #isTwoFactorPurpose(String)} — 공통 2FA 팝업의 유효 대상(purpose) 화이트리스트.
|
||||
* open redirect / 임의 purpose 로의 2FA 발송·통과권 발급을 막는 데 쓴다.</li>
|
||||
* <li>{@link #isInterceptorGuarded(String)} — {@link StepUpAuthInterceptor} 가 진입 시점에
|
||||
* 자동 차단하는 경로. 비밀번호 변경(반영 직전 확인)·내 정보(별도 확인 페이지)는
|
||||
* 각 컨트롤러가 직접 관장하므로 여기서 제외한다.</li>
|
||||
* <li>{@link #isPasswordGated(String)} — 현재 비밀번호 재확인으로 보호하는 경로.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어하며
|
||||
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
|
||||
*/
|
||||
public final class StepUpProtectedPaths {
|
||||
|
||||
/** step-up 검증 레벨(완화 정책) */
|
||||
public enum Level {
|
||||
/** 공통 2FA 팝업(인증번호) */
|
||||
TWO_FACTOR,
|
||||
/** 현재 비밀번호 재확인만 */
|
||||
PASSWORD
|
||||
}
|
||||
|
||||
/** Secret 키 조회 (AJAX POST) */
|
||||
public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
|
||||
/** 앱 해지 신청 (AJAX POST) */
|
||||
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
|
||||
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
|
||||
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
|
||||
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
||||
public static final String MYPAGE = "/mypage";
|
||||
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
||||
public static final String PASSWORD_CHANGE = "/password/change";
|
||||
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
|
||||
public static final String WITHDRAW = "/withdraw";
|
||||
|
||||
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||
|
||||
/** 경로 → 지점별 프로퍼티 키. 삽입 순서 유지(LinkedHashMap) */
|
||||
private static final Map<String, String> PATH_TO_KEY;
|
||||
/** 경로 → 검증 레벨 */
|
||||
private static final Map<String, Level> PATH_TO_LEVEL;
|
||||
/** 인터셉터가 진입 시점에 자동 차단하는 경로(2FA) */
|
||||
private static final Set<String> INTERCEPTOR_GUARDED;
|
||||
|
||||
static {
|
||||
Map<String, String> keys = new LinkedHashMap<>();
|
||||
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
|
||||
keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
|
||||
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
|
||||
keys.put(MYPAGE, KEY_PREFIX + "mypage");
|
||||
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
|
||||
PATH_TO_KEY = Collections.unmodifiableMap(keys);
|
||||
|
||||
Map<String, Level> levels = new LinkedHashMap<>();
|
||||
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
|
||||
levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
|
||||
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
|
||||
levels.put(MYPAGE, Level.PASSWORD);
|
||||
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
||||
levels.put(WITHDRAW, Level.TWO_FACTOR);
|
||||
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
||||
|
||||
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
||||
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
||||
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
|
||||
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
|
||||
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
||||
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
|
||||
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
|
||||
Set<String> guarded = new java.util.LinkedHashSet<>();
|
||||
guarded.add(REVEAL_SECRET);
|
||||
guarded.add(APP_KEY_DELETE);
|
||||
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
|
||||
}
|
||||
|
||||
private StepUpProtectedPaths() {
|
||||
}
|
||||
|
||||
/** 공통 2FA 팝업의 유효 대상(purpose)인지 — open redirect / 임의 purpose 차단용 */
|
||||
public static boolean isTwoFactorPurpose(String servletPath) {
|
||||
return servletPath != null
|
||||
&& PATH_TO_LEVEL.get(servletPath) == Level.TWO_FACTOR;
|
||||
}
|
||||
|
||||
/** 인터셉터가 진입 시점에 자동 차단하는 경로인지 */
|
||||
public static boolean isInterceptorGuarded(String servletPath) {
|
||||
return servletPath != null && INTERCEPTOR_GUARDED.contains(servletPath);
|
||||
}
|
||||
|
||||
/** 현재 비밀번호 재확인으로 보호하는 경로인지(PASSWORD 레벨) */
|
||||
public static boolean isPasswordGated(String servletPath) {
|
||||
return servletPath != null && PATH_TO_LEVEL.get(servletPath) == Level.PASSWORD;
|
||||
}
|
||||
|
||||
/** 해당 경로의 지점별 활성화 프로퍼티 키. 대상 경로가 아니면 null */
|
||||
public static String propertyKeyOf(String servletPath) {
|
||||
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
|
||||
}
|
||||
|
||||
/** 지점별 프로퍼티 키 접두 */
|
||||
public static String keyPrefix() {
|
||||
return KEY_PREFIX;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 만료된 2FA 인증번호(PTL_TWO_FACTOR_AUTH) 정리 스케줄러.
|
||||
*
|
||||
* <p>검증은 접근 시점 lazy 만료 검사만 하므로, 발송 후 검증 없이 방치된 레코드가 남는다.
|
||||
* 1분 주기로 만료분을 일괄 삭제한다.</p>
|
||||
*
|
||||
* <p><b>다중화(스케일아웃) 안전성:</b> 작업이 "만료된 행만" 지우는 멱등 delete 라
|
||||
* 여러 인스턴스가 동시에 실행해도 결과가 동일하고 부작용이 없다. 따라서 분산 락
|
||||
* (ShedLock 등)이 필요 없다. 동일 행을 둘이 지우려 하면 한쪽이 0건 삭제로 끝날 뿐이다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorCleanupScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorCleanupScheduler.class);
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
@Transactional
|
||||
public void cleanupExpired() {
|
||||
try {
|
||||
int deleted = twoFactorAuthRepository.deleteAllByExpiresAtBefore(LocalDateTime.now());
|
||||
if (deleted > 0) {
|
||||
log.debug("만료된 2FA 인증번호 {}건 정리", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("2FA 인증번호 정리 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 진행 중인 2FA 절차 상태. HTTP 세션에 단일 소스로 보관한다.
|
||||
*
|
||||
* <p>세션 클러스터링(stage/prod Redis/Ehcache) 대상이므로 {@link Serializable} 이다.
|
||||
* 여러 탭/페이지에서 동시에 2FA 가 발동되지 않도록, 발송 시 이 컨텍스트 존재 여부로
|
||||
* "진행 중" 을 판정하고 confirm 후 강제 종료(force)로만 새 절차를 시작한다.</p>
|
||||
*/
|
||||
public class TwoFactorContext implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public enum Mode {
|
||||
/** 로그인 1차 인증 통과 후 대기(pending) 상태의 2FA */
|
||||
LOGIN,
|
||||
/** 로그인 이후 민감기능 접근 시 추가 인증(step-up) */
|
||||
STEPUP
|
||||
}
|
||||
|
||||
private Mode mode;
|
||||
/** 발송 채널 (EMAIL | SMS) */
|
||||
private String channel;
|
||||
/** AuthNumberService 에 전달한 실제 수신처 문자열(이메일 소문자 / 휴대폰 digits). 검증 시 동일 값 사용 */
|
||||
private String recipient;
|
||||
/** step-up 대상 보호 경로(purpose). LOGIN 모드에서는 null */
|
||||
private String purpose;
|
||||
/** 발송 시각 */
|
||||
private LocalDateTime startedAt;
|
||||
/** 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 검증 시도 횟수 */
|
||||
private int attempts;
|
||||
|
||||
public Mode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public void setChannel(String channel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public String getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
public void setRecipient(String recipient) {
|
||||
this.recipient = recipient;
|
||||
}
|
||||
|
||||
public String getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public void setPurpose(String purpose) {
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
public LocalDateTime getStartedAt() {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
public void setStartedAt(LocalDateTime startedAt) {
|
||||
this.startedAt = startedAt;
|
||||
}
|
||||
|
||||
public int getTtlSeconds() {
|
||||
return ttlSeconds;
|
||||
}
|
||||
|
||||
public void setTtlSeconds(int ttlSeconds) {
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public void setAttempts(int attempts) {
|
||||
this.attempts = attempts;
|
||||
}
|
||||
|
||||
public int incrementAttempts() {
|
||||
return ++this.attempts;
|
||||
}
|
||||
|
||||
/** startedAt + ttl 기준 만료 여부(세션 컨텍스트 lazy 만료 판정용) */
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return startedAt == null || startedAt.plusSeconds(ttlSeconds).isBefore(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
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.ResponseBody;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
* 공통 2FA 팝업 백엔드. 로그인 pending·step-up 을 모두 처리한다.
|
||||
*
|
||||
* <p>수신처는 서버가 세션 대상 사용자로부터 결정하므로 클라이언트는 채널만 전달한다.
|
||||
* 모든 POST 는 세션 기반 CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/auth/2fa")
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorController {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
/** 팝업 초기 정보(채널·TTL·진행중 여부) */
|
||||
@GetMapping("/info")
|
||||
@ResponseBody
|
||||
public TwoFactorInfoResponse info(@RequestParam(required = false) String purpose, HttpSession session) {
|
||||
return twoFactorService.getInfo(session, purpose);
|
||||
}
|
||||
|
||||
/** 인증번호 발송 */
|
||||
@PostMapping("/send")
|
||||
@ResponseBody
|
||||
public TwoFactorSendResponse send(@RequestParam String channel,
|
||||
@RequestParam(required = false) String purpose,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean force,
|
||||
HttpSession session) {
|
||||
return twoFactorService.send(session, channel, purpose, force);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 */
|
||||
@PostMapping("/verify")
|
||||
@ResponseBody
|
||||
public TwoFactorVerifyResponse verify(@RequestParam String code,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
return twoFactorService.verify(request, session, code);
|
||||
}
|
||||
|
||||
/** 팝업 닫기/타이머 만료 → 2차 인증 실패 처리 */
|
||||
@PostMapping("/cancel")
|
||||
@ResponseBody
|
||||
public void cancel(@RequestParam(required = false, defaultValue = "CANCELLED") String reason,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
twoFactorService.cancel(request, session, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* step-up GET 진입 지점용 챌린지 페이지. 인터셉터가 리다이렉트하며, 화면이 공통 팝업을 자동 오픈한다.
|
||||
* returnUrl 은 보호 경로 화이트리스트로 검증(open redirect 방지)한다.
|
||||
*/
|
||||
@GetMapping("/challenge")
|
||||
public String challenge(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
// returnUrl 은 쿼리스트링을 포함할 수 있으므로 경로 부분만 화이트리스트로 검증(open redirect 방지)
|
||||
String purpose = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("returnUrl", returnUrl);
|
||||
model.addAttribute("purpose", purpose);
|
||||
return "apps/auth/twoFactorChallenge";
|
||||
}
|
||||
|
||||
private static String pathOf(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int q = url.indexOf('?');
|
||||
return q >= 0 ? url.substring(0, q) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 2FA 관련 PTL_PROPERTY 접근 래퍼.
|
||||
*
|
||||
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례({@code session.timeout.minutes},
|
||||
* {@code org.hard-delete.enabled} 등)를 따른다.
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
|
||||
* 생성(그룹 존재 시)하므로 별도 초기 데이터가 필요 없다. 캐시가 없어 매 호출 DB 조회지만
|
||||
* 2FA 진입 경로가 제한적이라 허용 범위다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
|
||||
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
|
||||
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 로그인 2FA 활성화 여부 */
|
||||
public boolean isLoginEnabled() {
|
||||
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 2FA 적용 대상 역할인지 여부.
|
||||
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
|
||||
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
|
||||
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
|
||||
*/
|
||||
public boolean isLoginTargetRole(RoleCode roleCode) {
|
||||
if (roleCode == null) {
|
||||
roleCode = RoleCode.ROLE_USER;
|
||||
}
|
||||
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
|
||||
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if ("ALL".equalsIgnoreCase(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
for (String token : trimmed.split(",")) {
|
||||
if (roleCode.name().equalsIgnoreCase(token.trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
|
||||
public boolean isStepUpEnabled() {
|
||||
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 보호 경로에 step-up 2FA 를 적용할지 여부(지점별 스위치).
|
||||
* 전체 스위치({@link #isStepUpEnabled()})가 켜진 상태에서 지점별로 개별 on/off 한다.
|
||||
* 지점 프로퍼티({@code two-factor.stepup.<지점>})의 기본값은 true(전체 스위치를 켜면 기본 전 지점 적용).
|
||||
*
|
||||
* @param servletPath 보호 경로. 매핑 키가 없으면(비보호 경로) false
|
||||
*/
|
||||
public boolean isStepUpPointEnabled(String servletPath) {
|
||||
String key = StepUpProtectedPaths.propertyKeyOf(servletPath);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
return parseBool(resolve(key, "true", "step-up 2FA 지점 적용 여부 (true/false): " + servletPath));
|
||||
}
|
||||
|
||||
/** 2FA 인증번호 유효시간(초). 기본 180초(3분) */
|
||||
public int getTtlSeconds() {
|
||||
return parseInt(resolve(KEY_TTL_SECONDS, "180", "2차 인증번호 유효시간(초)"), 180);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 시도 한도. 기본 5회 */
|
||||
public int getAttemptLimit() {
|
||||
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||
}
|
||||
|
||||
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
||||
public boolean isTestNoticeEnabled() {
|
||||
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* boolean PTL_PROPERTY 값 파싱.
|
||||
* DB 관례에 맞춰 <b>true/false</b> 문자열을 사용한다(예: {@code org.hard-delete.enabled=true}).
|
||||
* "true"(대소문자 무시)만 참으로 본다. 그 외(false/공백/null 등)는 모두 거짓.
|
||||
*/
|
||||
private static boolean parseBool(String value) {
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
private static int parseInt(String value, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (Exception e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberStorage;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorChannel;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
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.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
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 com.eactive.apim.portal.portaluser.service.AuthNumberException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 2FA(2차 인증/추가 인증) 공통 서비스. 로그인 pending 인증과 step-up(민감기능) 인증을 모두 처리한다.
|
||||
*
|
||||
* <p>핵심 원칙:
|
||||
* <ul>
|
||||
* <li>수신처는 서버가 세션의 대상 사용자로부터 DB 기준으로 결정한다(클라이언트는 채널만 선택).</li>
|
||||
* <li>진행 상태는 세션 {@link TwoFactorContext} 단일 소스로 관리한다.</li>
|
||||
* <li>다른 플로우가 진행 중이면 발송을 막고(inProgress), confirm 후 force 로만 강제 종료·재시작한다.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorService.class);
|
||||
|
||||
// === 세션 attribute 키 ===
|
||||
/** 로그인 1차 인증 통과 후 대기 중인 사용자 id (존재 시 LOGIN 모드) */
|
||||
public static final String ATTR_PENDING_USER_ID = "TFA_PENDING_USER_ID";
|
||||
/** 대기 중 사용자 loginId (감사/세션 표기) */
|
||||
public static final String ATTR_PENDING_LOGIN_ID = "TFA_PENDING_LOGIN_ID";
|
||||
/** 진행 중 2FA 컨텍스트 */
|
||||
public static final String ATTR_CONTEXT = "TFA_CONTEXT";
|
||||
/** step-up 1회용 통과권 - 대상 경로 */
|
||||
public static final String ATTR_STEPUP_PASS_PATH = "TFA_STEPUP_PASS_PATH";
|
||||
/** step-up 1회용 통과권 - 발급 시각 */
|
||||
public static final String ATTR_STEPUP_PASS_AT = "TFA_STEPUP_PASS_AT";
|
||||
|
||||
/** step-up 통과권 유효시간(초). 인증 성공 후 대상 페이지 진입까지의 이동 여유분 */
|
||||
public static final int STEPUP_PASS_TTL_SECONDS = 120;
|
||||
/** 재발송/채널전환 통합 최소 간격(초) */
|
||||
private static final int RESEND_THROTTLE_SECONDS = 30;
|
||||
|
||||
private final TwoFactorProperties properties;
|
||||
private final AuthNumberService authNumberService;
|
||||
private final AuthNumberStorage authNumberStorage;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
// =========================================================================
|
||||
// INFO
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorInfoResponse getInfo(HttpSession session, String purpose) {
|
||||
TwoFactorInfoResponse res = new TwoFactorInfoResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
|
||||
res.setAvailable(true);
|
||||
res.setMode(mode.name());
|
||||
res.setChannels(buildChannels(user));
|
||||
res.setTtlSeconds(properties.getTtlSeconds());
|
||||
res.setTestNoticeEnabled(properties.isTestNoticeEnabled());
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null && !isSameFlow(ctx, mode, purpose)) {
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다.");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SEND
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
|
||||
TwoFactorSendResponse res = new TwoFactorSendResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 정보가 없습니다. 다시 시도해주세요.");
|
||||
return res;
|
||||
}
|
||||
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isTwoFactorPurpose(purpose)) {
|
||||
res.setValid(false);
|
||||
res.setMessage("허용되지 않은 요청입니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 사용자를 찾을 수 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
String normalizedChannel = channel == null ? "" : channel.trim().toUpperCase();
|
||||
String recipient = resolveRecipient(user, normalizedChannel);
|
||||
if (recipient == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("선택한 방법으로 인증할 수 있는 정보가 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 진행 중 컨텍스트 처리
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null) {
|
||||
boolean sameFlow = isSameFlow(ctx, mode, purpose);
|
||||
if (!sameFlow) {
|
||||
if (!force) {
|
||||
res.setValid(false);
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
|
||||
return res;
|
||||
}
|
||||
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
|
||||
} else if (ctx.getStartedAt() != null
|
||||
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
|
||||
res.setValid(false);
|
||||
res.setMessage("잠시 후에 다시 시도해 주세요.");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
int ttl = properties.getTtlSeconds();
|
||||
String authNumber;
|
||||
try {
|
||||
authNumber = authNumberService.sendRequestAuthNumber(recipient,
|
||||
"SMS".equals(normalizedChannel) ? "SMS" : "EMAIL", ttl);
|
||||
} catch (AuthNumberException e) {
|
||||
res.setValid(false);
|
||||
res.setMessage(e.getMessage());
|
||||
return res;
|
||||
}
|
||||
|
||||
TwoFactorContext newCtx = new TwoFactorContext();
|
||||
newCtx.setMode(mode);
|
||||
newCtx.setChannel(normalizedChannel);
|
||||
newCtx.setRecipient(recipient);
|
||||
newCtx.setPurpose(mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
newCtx.setStartedAt(LocalDateTime.now());
|
||||
newCtx.setTtlSeconds(ttl);
|
||||
newCtx.setAttempts(0);
|
||||
session.setAttribute(ATTR_CONTEXT, newCtx);
|
||||
|
||||
res.setValid(true);
|
||||
res.setMessage("인증번호를 발송하였습니다.");
|
||||
res.setTtlSeconds(ttl);
|
||||
if (properties.isTestNoticeEnabled()) {
|
||||
res.setTestAuthNumber(authNumber);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// VERIFY
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorVerifyResponse verify(HttpServletRequest request, HttpSession session, String code) {
|
||||
TwoFactorVerifyResponse res = new TwoFactorVerifyResponse();
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx == null) {
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시간이 만료되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
if (ctx.isExpired(LocalDateTime.now())) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
int attempts = ctx.incrementAttempts();
|
||||
int limit = properties.getAttemptLimit();
|
||||
|
||||
try {
|
||||
authNumberService.verifyAuthNumber(ctx.getRecipient(), code);
|
||||
} catch (AuthNumberException e) {
|
||||
AuthNumberException.Reason reason = e.getReason();
|
||||
if (reason == AuthNumberException.Reason.EXPIRED || reason == AuthNumberException.Reason.NOT_FOUND) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
// 코드 불일치
|
||||
if (attempts >= limit) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_ATTEMPT_EXCEEDED, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시도 횟수를 초과했습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
session.setAttribute(ATTR_CONTEXT, ctx); // attempts 갱신 반영
|
||||
res.setValid(false);
|
||||
res.setRemainingAttempts(limit - attempts);
|
||||
res.setMessage("인증번호가 일치하지 않습니다. (남은 횟수 " + (limit - attempts) + "회)");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 검증 성공 — 인증번호 즉시 소비(재사용 방지, 2FA 한정)
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
return completeLogin(request, session, res);
|
||||
}
|
||||
|
||||
// STEPUP — 1회용 통과권 발급
|
||||
issueStepUpPass(session, ctx.getPurpose());
|
||||
res.setValid(true);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
private TwoFactorVerifyResponse completeLogin(HttpServletRequest request, HttpSession session,
|
||||
TwoFactorVerifyResponse res) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
|
||||
PortalUser user = userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
if (user == null) {
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 1차 인증~2FA 사이 상태 변경 방어(잠금/차단/승인 취소)
|
||||
String stateError = revalidateLoginState(user);
|
||||
if (stateError != null) {
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.ACCOUNT_DISABLED);
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage(stateError);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
String redirect = loginFinalizer.finalizeLogin(user, loginId, request, LoginType.TWO_FACTOR);
|
||||
clearPending(session);
|
||||
|
||||
res.setValid(true);
|
||||
res.setRedirect(redirect);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CANCEL (팝업 닫기 / 타이머 만료)
|
||||
// =========================================================================
|
||||
|
||||
public void cancel(HttpServletRequest request, HttpSession session, String reason) {
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
boolean timeout = "TIMEOUT".equalsIgnoreCase(reason);
|
||||
LoginFailureReason failureReason = timeout
|
||||
? LoginFailureReason.TWO_FACTOR_TIMEOUT : LoginFailureReason.TWO_FACTOR_CANCELLED;
|
||||
|
||||
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason);
|
||||
}
|
||||
if (ctx != null && ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
// 로그인 2FA 취소는 로그인 자체를 포기(익명 유지) → pending 제거
|
||||
if (ctx == null || ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
clearPending(session);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LOGIN pending 진입 (SuccessHandler 에서 호출)
|
||||
// =========================================================================
|
||||
|
||||
/** 로그인 1차 인증 통과 사용자를 2FA 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||
public void beginLoginChallenge(HttpSession session, PortalUser user) {
|
||||
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
public boolean hasPendingLogin(HttpSession session) {
|
||||
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STEP-UP 통과권
|
||||
// =========================================================================
|
||||
|
||||
private void issueStepUpPass(HttpSession session, String path) {
|
||||
session.setAttribute(ATTR_STEPUP_PASS_PATH, path);
|
||||
session.setAttribute(ATTR_STEPUP_PASS_AT, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 보호 경로 수정 저장 직후 원경로로 되돌아가는 즉시 왕복(예: {@code /mypage} 수정 →
|
||||
* {@code redirect:/mypage})에서 중복 step-up 을 막기 위해 통과권을 재발급한다.
|
||||
*
|
||||
* <p>경로 고정 + TTL({@link #STEPUP_PASS_TTL_SECONDS}s) 로 <b>1회 왕복만</b> 커버하며,
|
||||
* 이후 새로 {@code /mypage} 에 진입하면 정상적으로 다시 인증을 요구한다("매번 인증" 유지).</p>
|
||||
*/
|
||||
public void grantStepUpPass(HttpSession session, String path) {
|
||||
if (session != null && path != null) {
|
||||
issueStepUpPass(session, path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 경로에 대한 유효한 1회용 통과권이 있으면 소비(제거)하고 true 를 반환한다.
|
||||
* (매번 인증 정책 — 통과권은 즉시 소멸)
|
||||
*/
|
||||
public boolean consumeStepUpPass(HttpSession session, String servletPath) {
|
||||
Object passPath = session.getAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
Object passAt = session.getAttribute(ATTR_STEPUP_PASS_AT);
|
||||
if (!(passPath instanceof String) || !(passAt instanceof LocalDateTime)) {
|
||||
return false;
|
||||
}
|
||||
boolean valid = passPath.equals(servletPath)
|
||||
&& ((LocalDateTime) passAt).plusSeconds(STEPUP_PASS_TTL_SECONDS).isAfter(LocalDateTime.now());
|
||||
// 매번 인증: 일치/불일치 무관하게 통과권은 이번 판정에서 소멸시킨다.
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_AT);
|
||||
return valid;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 helper
|
||||
// =========================================================================
|
||||
|
||||
private TwoFactorContext.Mode resolveMode(HttpSession session) {
|
||||
if (session.getAttribute(ATTR_PENDING_USER_ID) != null) {
|
||||
return TwoFactorContext.Mode.LOGIN;
|
||||
}
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return TwoFactorContext.Mode.STEPUP;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private PortalUser resolveTargetUser(HttpSession session, TwoFactorContext.Mode mode) {
|
||||
if (mode == TwoFactorContext.Mode.LOGIN) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
return userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
}
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
// 세션 로드 이후 연락처 변경 반영을 위해 DB 재조회
|
||||
return portalUserRepository.findById(current.getId()).orElse(null);
|
||||
}
|
||||
|
||||
private List<TwoFactorChannel> buildChannels(PortalUser user) {
|
||||
List<TwoFactorChannel> channels = new ArrayList<>();
|
||||
if (StringUtils.hasText(user.getEmailAddr())) {
|
||||
channels.add(new TwoFactorChannel("EMAIL", StringMaskingUtil.maskEmail(user.getEmailAddr())));
|
||||
}
|
||||
if (StringUtils.hasText(user.getMobileNumber())) {
|
||||
channels.add(new TwoFactorChannel("SMS", StringMaskingUtil.maskMobileNumber(user.getMobileNumber())));
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
private String resolveRecipient(PortalUser user, String channel) {
|
||||
if ("EMAIL".equals(channel)) {
|
||||
return StringUtils.hasText(user.getEmailAddr()) ? user.getEmailAddr() : null;
|
||||
}
|
||||
if ("SMS".equals(channel)) {
|
||||
return StringUtils.hasText(user.getMobileNumber())
|
||||
? PhoneNumberUtil.digitsOnly(user.getMobileNumber()) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private TwoFactorContext getActiveContext(HttpSession session) {
|
||||
Object ctx = session.getAttribute(ATTR_CONTEXT);
|
||||
if (!(ctx instanceof TwoFactorContext)) {
|
||||
return null;
|
||||
}
|
||||
TwoFactorContext context = (TwoFactorContext) ctx;
|
||||
if (context.isExpired(LocalDateTime.now())) {
|
||||
// 만료 컨텍스트는 정리(감사는 verify/cancel 경로에서 처리)
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
if (context.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(context.getRecipient());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private boolean isSameFlow(TwoFactorContext ctx, TwoFactorContext.Mode mode, String purpose) {
|
||||
return ctx.getMode() == mode && Objects.equals(ctx.getPurpose(),
|
||||
mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
}
|
||||
|
||||
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
|
||||
private void discardContext(HttpSession session, TwoFactorContext ctx) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
/** 검증 실패로 절차 종료: 인증번호 삭제 + 감사 + 컨텍스트/pending 정리 */
|
||||
private void terminateWithFailure(HttpSession session, TwoFactorContext ctx,
|
||||
LoginFailureReason reason, HttpServletRequest request) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason);
|
||||
clearPending(session);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
private void clearPending(HttpSession session) {
|
||||
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
}
|
||||
|
||||
/** 1차 인증~2FA 사이 계정 상태 재검증. 문제 있으면 사용자 안내 메시지 반환, 정상이면 null */
|
||||
private String revalidateLoginState(PortalUser user) {
|
||||
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||
return "계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.";
|
||||
}
|
||||
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||
return "법인 관리자에 의해 비활성화된 계정입니다.";
|
||||
}
|
||||
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||
return "사용자 승인 대기중입니다.";
|
||||
}
|
||||
if (user.getPortalOrg() != null
|
||||
&& !PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus())) {
|
||||
return "로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/** 2FA 발송 가능 채널 1건. masked 는 화면 표기용 마스킹 수신처. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class TwoFactorChannel {
|
||||
/** EMAIL | SMS */
|
||||
private String type;
|
||||
/** 마스킹된 수신처 (예: te**@ex**.com, 010-12**-34**) */
|
||||
private String masked;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** GET /auth/2fa/info 응답. 팝업 초기 렌더용. */
|
||||
@Data
|
||||
public class TwoFactorInfoResponse {
|
||||
/** 컨텍스트 유효 여부(로그인 pending 또는 인증 사용자). false 면 팝업 진입 불가 */
|
||||
private boolean available;
|
||||
/** LOGIN | STEPUP */
|
||||
private String mode;
|
||||
/** 발송 가능 채널(휴대폰 없으면 이메일만) */
|
||||
private List<TwoFactorChannel> channels;
|
||||
/** 인증번호 유효시간(초) — 타이머 초기값 */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호 노출 여부 */
|
||||
private boolean testNoticeEnabled;
|
||||
/** 이미 진행 중인 절차 존재 여부(다른 탭/페이지) */
|
||||
private boolean inProgress;
|
||||
/** 진행 중인 절차의 안내 메시지(있으면) */
|
||||
private String message;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/send 응답. */
|
||||
@Data
|
||||
public class TwoFactorSendResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** 타이머 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호(테스트 노출 활성 시에만 채워짐) */
|
||||
private String testAuthNumber;
|
||||
/** 이미 진행 중인 절차가 있어 발송을 막은 경우 true (confirm 후 force 재요청 유도) */
|
||||
private boolean inProgress;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/verify 응답. */
|
||||
@Data
|
||||
public class TwoFactorVerifyResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** LOGIN 모드 성공 시 이동 대상 URL */
|
||||
private String redirect;
|
||||
/** 실패 시 남은 시도 횟수 */
|
||||
private int remainingAttempts;
|
||||
/** 시도 초과/타임아웃 등으로 절차가 강제 종료되어 재시작이 필요한 경우 true */
|
||||
private boolean terminated;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.community.notice.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.TimelineEntryDTO;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -53,6 +54,14 @@ public class PortalNoticeDTO {
|
||||
private String state;
|
||||
private String previousState;
|
||||
private List<IncidentAffectedApiDTO> affectedApis = Collections.emptyList();
|
||||
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||
private int hiddenApiCount;
|
||||
/** 장애 처리 타임라인. 최신순(내림차순), 공개(visibleYn='Y') 항목만 담는다. */
|
||||
private List<TimelineEntryDTO> timeline = Collections.emptyList();
|
||||
|
||||
public boolean hasTimeline() {
|
||||
return timeline != null && !timeline.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isIncidentType() {
|
||||
return NOTICE_TYPE_INCIDENT.equals(noticeType);
|
||||
|
||||
+7
@@ -2,11 +2,18 @@ package com.eactive.apim.portal.apps.community.notice.repository;
|
||||
|
||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
@EMSDataSource
|
||||
public interface PortalNoticeRepository extends JpaRepository<PortalNotice, String>, JpaSpecificationExecutor<PortalNotice> {
|
||||
|
||||
/**
|
||||
* 게시 중인 공지만 골라 한 번에 읽는다. API Status 카드가 연결 공지 본문을 붙일 때 사용한다.
|
||||
* 미게시(USE_YN='N')·삭제된 공지는 결과에서 자연히 빠진다.
|
||||
*/
|
||||
List<PortalNotice> findByIdInAndUseYn(Collection<String> ids, String useYn);
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -5,6 +5,9 @@ import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeDTO;
|
||||
import com.eactive.apim.portal.apps.community.notice.dto.PortalNoticeSearch;
|
||||
import com.eactive.apim.portal.apps.community.notice.mapper.PortalNoticeMapper;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusAssembler;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||
@@ -16,8 +19,10 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -29,6 +34,8 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
private final PortalNoticeMapper portalNoticeMapper;
|
||||
private final DjbApistatusIncidentRepository incidentRepository;
|
||||
private final DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
private final ApiStatusAssembler apiStatusAssembler;
|
||||
private final ApiStatusCatalogService apiStatusCatalogService;
|
||||
|
||||
@Override
|
||||
public List<PortalNoticeDTO> getLatestNotices() {
|
||||
@@ -67,11 +74,13 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
private void populateIncident(PortalNoticeDTO dto) {
|
||||
if (!dto.isIncidentOrMaintenance()) {
|
||||
dto.setAffectedApis(Collections.emptyList());
|
||||
dto.setTimeline(Collections.emptyList());
|
||||
return;
|
||||
}
|
||||
Optional<DjbApistatusIncident> incidentOpt = incidentRepository.findByNoticeId(dto.getId());
|
||||
if (!incidentOpt.isPresent()) {
|
||||
dto.setAffectedApis(Collections.emptyList());
|
||||
dto.setTimeline(Collections.emptyList());
|
||||
return;
|
||||
}
|
||||
DjbApistatusIncident incident = incidentOpt.get();
|
||||
@@ -81,10 +90,28 @@ public class PortalNoticeFacadeImpl implements PortalNoticeFacade {
|
||||
dto.setState(incident.getState() == null ? null : incident.getState().name());
|
||||
dto.setPreviousState(incident.getPreviousState() == null ? null : incident.getPreviousState().name());
|
||||
|
||||
List<IncidentAffectedApiDTO> apis = incidentApiRepository
|
||||
.findByIncidentIdOrderByApiId(incident.getIncidentId()).stream()
|
||||
.map(api -> new IncidentAffectedApiDTO(api.getApiId(), api.getApiName()))
|
||||
.collect(Collectors.toList());
|
||||
// 영향 인터페이스 중 개발자포탈에 게시된 API 만 개별 노출한다.
|
||||
// 나머지 GW 인터페이스는 이름·ID 를 감추고 건수로만 알린다.
|
||||
Map<String, String> visibleNames = apiStatusCatalogService.getVisibleApiNames();
|
||||
List<IncidentAffectedApiDTO> apis = new ArrayList<>();
|
||||
int hiddenCount = 0;
|
||||
for (DjbApistatusIncidentApi api :
|
||||
incidentApiRepository.findByIncidentIdOrderByApiId(incident.getIncidentId())) {
|
||||
String publishedName = visibleNames.get(api.getApiId());
|
||||
if (publishedName == null) {
|
||||
hiddenCount++;
|
||||
continue;
|
||||
}
|
||||
apis.add(new IncidentAffectedApiDTO(api.getApiId(), publishedName));
|
||||
}
|
||||
dto.setAffectedApis(apis);
|
||||
dto.setHiddenApiCount(hiddenCount);
|
||||
|
||||
// 장애·지연만 타임라인을 붙인다 (점검은 타임라인을 쌓지 않음 — ADR-F15)
|
||||
boolean degrading = incident.getKind() != null && incident.getKind().isDegrading();
|
||||
dto.setTimeline(degrading
|
||||
? apiStatusAssembler.loadTimelines(Collections.singletonList(incident.getIncidentId()))
|
||||
.getOrDefault(incident.getIncidentId(), Collections.emptyList())
|
||||
: Collections.emptyList());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ public class PartnershipApplicationController {
|
||||
@GetMapping
|
||||
public String newPartnershipApplicationForm(Model model, HttpServletRequest request) {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?redirect=/partnership";
|
||||
return "redirect:/login?reason=auth&redirect=/partnership";
|
||||
}
|
||||
|
||||
String referer = request.getHeader("Referer");
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ 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.djb.swing.SwingNotifier;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
@@ -29,7 +29,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
||||
private final FileService fileService;
|
||||
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
||||
private final CommunityAdminNotifier portalAdminNotifier;
|
||||
private final SwingNotifier swingNotifier;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -61,7 +61,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
if (writer != null) {
|
||||
params.put("writerName", writer.getUserName());
|
||||
}
|
||||
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||
swingNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+3
-3
@@ -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.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.djb.swing.SwingNotifier;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
@@ -40,7 +40,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryMapper inquiryMapper;
|
||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||
private final SwingNotifier swingNotifier;
|
||||
private final FileService fileService;
|
||||
|
||||
@Override
|
||||
@@ -142,7 +142,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
params.put("inquiryId", inquiry.getId());
|
||||
params.put("inquirySubject", inquiry.getInquirySubject());
|
||||
params.put("writerName", current.getUserName());
|
||||
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
|
||||
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.login.constants;
|
||||
|
||||
/**
|
||||
* 로그인 실패 사유 코드. PTL_USER_LOG.FAILURE_REASON 에 문자열(name())로 저장된다.
|
||||
*/
|
||||
public enum LoginFailureReason {
|
||||
|
||||
/** 아이디(이메일) 미존재 */
|
||||
ID_NOT_FOUND,
|
||||
/** 비밀번호 불일치 */
|
||||
PASSWORD_MISMATCH,
|
||||
/** 계정 잠금(5회 실패 등) */
|
||||
ACCOUNT_LOCKED,
|
||||
/** 비활성 계정(승인 대기/관리자 차단/법인 미승인) */
|
||||
ACCOUNT_DISABLED,
|
||||
/** 세션 인증 오류(중복 로그인 등) */
|
||||
SESSION_AUTH,
|
||||
/** 2차 인증 - 인증번호 유효시간 초과 */
|
||||
TWO_FACTOR_TIMEOUT,
|
||||
/** 2차 인증 - 인증번호 불일치 */
|
||||
TWO_FACTOR_CODE_MISMATCH,
|
||||
/** 2차 인증 - 사용자가 팝업을 닫아 취소 */
|
||||
TWO_FACTOR_CANCELLED,
|
||||
/** 2차 인증 - 시도 횟수 초과 */
|
||||
TWO_FACTOR_ATTEMPT_EXCEEDED,
|
||||
/** 분류 불가 */
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.login.constants;
|
||||
|
||||
/**
|
||||
* 로그인 유형 코드. PTL_USER_LOG.LOGIN_TYPE 에 문자열(name())로 저장된다.
|
||||
*/
|
||||
public enum LoginType {
|
||||
|
||||
/** 일반 로그인 (2FA 미적용) */
|
||||
NORMAL,
|
||||
/** 2차 인증을 통과한 로그인 */
|
||||
TWO_FACTOR,
|
||||
/** 회원가입 직후 자동 로그인 (2FA 미적용) */
|
||||
SIGNUP_AUTO
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.eactive.apim.portal.apps.login.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 동시 접속(중복 세션) 확인 대기 상태(로그인 2FA off 경로)의 확정/취소 API.
|
||||
*
|
||||
* <p>대기 상태는 1차 인증(ID/PW) 성공 후 SuccessHandler 만 세팅하므로, 이 엔드포인트는
|
||||
* 비밀번호 검증을 통과한 세션에서만 의미가 있다. 모든 POST 는 세션 기반
|
||||
* CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/login/duplicate")
|
||||
@RequiredArgsConstructor
|
||||
public class DuplicateLoginController {
|
||||
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
/** 기존 접속 해제 확인 → 로그인 확정. 무효(만료/상태 변경) 시 재로그인 안내 */
|
||||
@PostMapping("/confirm")
|
||||
public Map<String, Object> confirm(HttpServletRequest request, HttpSession session) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String redirect = duplicateLoginService.confirm(request, session);
|
||||
if (redirect != null) {
|
||||
result.put("valid", true);
|
||||
result.put("redirect", redirect);
|
||||
} else {
|
||||
result.put("valid", false);
|
||||
result.put("message", "로그인 확인이 만료되었습니다. 다시 로그인해주세요.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 확인 취소 — 로그인 포기(익명 유지) */
|
||||
@PostMapping("/cancel")
|
||||
public Map<String, Object> cancel(HttpSession session) {
|
||||
duplicateLoginService.cancel(session);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("valid", true);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.login.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||
import com.eactive.apim.portal.common.exception.PortalRedirectException;
|
||||
import com.eactive.apim.portal.common.pagerouter.PageHandler;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -22,6 +24,13 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
|
||||
@Component("LoginHandler")
|
||||
public class LoginHandler implements PageHandler {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.duplicateLoginService = duplicateLoginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 화면으로 들어간다
|
||||
@@ -47,6 +56,27 @@ public class LoginHandler implements PageHandler {
|
||||
session.removeAttribute("loginId");
|
||||
}
|
||||
|
||||
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
|
||||
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
|
||||
boolean twoFactorPending = twoFactorService.hasPendingLogin(session);
|
||||
model.addAttribute("twoFactorPending", twoFactorPending);
|
||||
|
||||
// 동시 접속 안내 — 반드시 1차 인증 통과 후(2FA pending 또는 중복 확인 대기)에만 노출한다.
|
||||
// - 2FA on: 확인 후 2FA 팝업 진행(취소 시 /auth/2fa/cancel)
|
||||
// - 2FA off: 확인 후 /login/duplicate/confirm 으로 확정
|
||||
String pendingLoginId = null;
|
||||
boolean duplicateConfirmPending = false;
|
||||
if (twoFactorPending) {
|
||||
pendingLoginId = (String) session.getAttribute(TwoFactorService.ATTR_PENDING_LOGIN_ID);
|
||||
} else if (duplicateLoginService.hasPending(session)
|
||||
&& "1".equals(httpRequest.getParameter("duplicate"))) {
|
||||
pendingLoginId = duplicateLoginService.pendingLoginId(session);
|
||||
duplicateConfirmPending = true;
|
||||
}
|
||||
model.addAttribute("duplicateConfirmPending", duplicateConfirmPending);
|
||||
model.addAttribute("duplicateInfo",
|
||||
pendingLoginId != null ? duplicateLoginService.activeSessionInfo(pendingLoginId) : null);
|
||||
|
||||
// 이미 인증된 사용자인지 확인
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && !"anonymousUser".equalsIgnoreCase(authentication.getPrincipal().toString())) {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.eactive.apim.portal.apps.login.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 로그인 시 동시 접속(중복 세션) 확인 처리.
|
||||
*
|
||||
* <p>중복 확인은 반드시 <b>1차 인증(ID/PW) 성공 후</b>에만 수행한다. 비밀번호 검증 전에
|
||||
* 노출하면 임의 계정의 접속 여부·IP 가 인증 없이 조회되는 정보 노출이 된다(기존
|
||||
* {@code /api/session/check-duplicate} 사전 체크 방식의 문제).</p>
|
||||
*
|
||||
* <p>두 경로에서 쓰인다:
|
||||
* <ul>
|
||||
* <li>로그인 2FA on — 2FA pending 상태의 로그인 페이지가 {@link #activeSessionInfo(String)}
|
||||
* 로 안내 정보를 내려주고, 확인 후 2FA 팝업으로 진행(취소 시 {@code /auth/2fa/cancel}).</li>
|
||||
* <li>로그인 2FA off — SuccessHandler 가 확정을 보류하고 {@link #begin} 으로 대기 상태 전환.
|
||||
* 사용자가 확인하면 {@link #confirm} 이 인증을 확정한다(기존 세션은
|
||||
* {@link LoginFinalizer#finalizeLogin} 의 forceLogoutOtherSessions 로 해제).</li>
|
||||
* </ul></p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class DuplicateLoginService {
|
||||
|
||||
/** 동시 접속 확인 대기 - 대상 사용자 id (2FA off 경로) */
|
||||
public static final String ATTR_PENDING_USER_ID = "DUP_PENDING_USER_ID";
|
||||
/** 동시 접속 확인 대기 - loginId */
|
||||
public static final String ATTR_PENDING_LOGIN_ID = "DUP_PENDING_LOGIN_ID";
|
||||
/** 동시 접속 확인 대기 - 진입 시각 */
|
||||
public static final String ATTR_PENDING_AT = "DUP_PENDING_AT";
|
||||
|
||||
/** 확인 대기 유효시간(초). 초과 시 처음부터 재로그인 */
|
||||
public static final int PENDING_TTL_SECONDS = 120;
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
/** 해당 계정의 활성 세션(다른 곳 접속) 존재 여부 */
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasActiveSession(String loginId) {
|
||||
return activeSession(loginId).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 세션 안내 정보(마스킹 IP·접속 시각). 없으면 null.
|
||||
* 로그인 페이지 확인 팝업 표시용 — 1차 인증 통과 후에만 호출해야 한다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, String> activeSessionInfo(String loginId) {
|
||||
Optional<UserSession> active = activeSession(loginId);
|
||||
if (!active.isPresent()) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> info = new HashMap<>();
|
||||
info.put("ipAddress", maskIpAddress(active.get().getIpAddress()));
|
||||
info.put("loginTime", active.get().getLoginTime().format(TIME_FORMATTER));
|
||||
return info;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2FA off 경로: 확정 보류 → 확인 → 확정
|
||||
// =========================================================================
|
||||
|
||||
/** 1차 인증 성공 사용자를 동시 접속 확인 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||
public void begin(HttpSession session, PortalUser user) {
|
||||
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||
session.setAttribute(ATTR_PENDING_AT, LocalDateTime.now());
|
||||
}
|
||||
|
||||
public boolean hasPending(HttpSession session) {
|
||||
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||
}
|
||||
|
||||
public String pendingLoginId(HttpSession session) {
|
||||
return session == null ? null : (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시 접속 확인 후 로그인 확정. 대기 상태가 유효하면 인증을 세팅하고 최종 이동 URL 을
|
||||
* 반환한다(기존 세션 해제 포함). 무효(만료/상태 변경)면 null — 재로그인 필요.
|
||||
*/
|
||||
public String confirm(HttpServletRequest request, HttpSession session) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
Object at = session.getAttribute(ATTR_PENDING_AT);
|
||||
cancel(session); // 1회용 — 성공/실패 무관하게 대기 상태는 소멸
|
||||
|
||||
if (userId == null || !(at instanceof LocalDateTime)
|
||||
|| ((LocalDateTime) at).plusSeconds(PENDING_TTL_SECONDS).isBefore(LocalDateTime.now())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PortalUser user = portalUserRepository.findById(userId).orElse(null);
|
||||
if (user == null || !isLoginStillAllowed(user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
return loginFinalizer.finalizeLogin(user, loginId, request, LoginType.NORMAL);
|
||||
}
|
||||
|
||||
/** 확인 취소 — 대기 상태 정리(익명 유지) */
|
||||
public void cancel(HttpSession session) {
|
||||
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
session.removeAttribute(ATTR_PENDING_AT);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 helper
|
||||
// =========================================================================
|
||||
|
||||
private Optional<UserSession> activeSession(String loginId) {
|
||||
if (loginId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return userSessionService.getActiveSession(loginId.toLowerCase());
|
||||
}
|
||||
|
||||
/** 1차 인증~확인 사이 계정 상태 변경 방어 (TwoFactorService.revalidateLoginState 와 동일 기준) */
|
||||
private boolean isLoginStillAllowed(PortalUser user) {
|
||||
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||
return false;
|
||||
}
|
||||
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||
return false;
|
||||
}
|
||||
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||
return false;
|
||||
}
|
||||
return user.getPortalOrg() == null
|
||||
|| PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환). 예: 192.168.240.178 → 192.168.***.178
|
||||
*/
|
||||
private static 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,284 @@
|
||||
package com.eactive.apim.portal.apps.login.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
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.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
|
||||
import com.eactive.apim.portal.config.PasswordEnforcementPolicy;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
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.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.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 로그인 최종 확정(finalize) 처리를 공통화한다.
|
||||
*
|
||||
* <p>기존 {@code PortalAuthenticationSuccessHandler} 에 인라인되어 있던 후처리
|
||||
* (실패카운트 리셋, 감사 성공 기록, 후속 유도 세션 플래그, 초대 확인, 중복로그인 정리,
|
||||
* 물리 세션 타임아웃, 최종 이동 URL 결정)를 여기로 추출했다.</p>
|
||||
*
|
||||
* <p>세 경로가 이 로직을 공유한다:
|
||||
* <ul>
|
||||
* <li>일반 로그인 — 2FA off 시 SuccessHandler 가 직접 호출({@link LoginType#NORMAL})</li>
|
||||
* <li>로그인 2FA 통과 — TwoFactorService 가 호출({@link LoginType#TWO_FACTOR})</li>
|
||||
* <li>회원가입 자동 로그인 — 가입 컨트롤러가 호출({@link LoginType#SIGNUP_AUTO})</li>
|
||||
* </ul>
|
||||
* 최종 이동 URL 을 반환하며, 리다이렉트(HTTP 302)는 호출부 책임이다.</p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class LoginFinalizer {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
/**
|
||||
* 미인증 상태로 보호 페이지 접근 시 저장해 둔 원래 요청 경로(로그인+2FA 완료 후 복귀 대상).
|
||||
* {@code PortalGlobalExceptionHandler} 가 저장하고 여기서 소비(1회용)한다.
|
||||
*/
|
||||
public static final String SESSION_POST_LOGIN_REDIRECT = "postLoginRedirect";
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
|
||||
*
|
||||
* @param user 인증된 사용자
|
||||
* @param rawUsername 감사/세션 표기에 쓸 사용자 식별자(로그인 폼 입력 원본 또는 loginId)
|
||||
* @param request 현재 요청(IP/헤더/세션)
|
||||
* @param loginType 로그인 유형(감사 기록용)
|
||||
* @return 리다이렉트 대상 URL (contextPath 포함)
|
||||
*/
|
||||
public String finalizeLogin(PortalUser user, String rawUsername, HttpServletRequest request, LoginType loginType) {
|
||||
String normalizedUsername = rawUsername != null ? rawUsername.toLowerCase() : null;
|
||||
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType);
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
|
||||
applyPostLoginState(user, session, rawUsername, contextPath);
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 (메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
|
||||
session.setAttribute("pendingInvitation", true);
|
||||
session.setAttribute("pendingInvitationToken", invitation.getToken());
|
||||
String orgName = portalOrgRepository.findById(invitation.getOrgId())
|
||||
.map(PortalOrg::getOrgName)
|
||||
.orElse("알 수 없는 기관");
|
||||
session.setAttribute("pendingInvitationOrgName", orgName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 법인 사용자(관리자/개발자) 로그인 시, 홈 최초 진입에서 클라이언트 신규 신청 유도 팝업을 1회 노출하도록 마킹한다.
|
||||
// 실제 클라이언트(승인+요청중) 보유 여부 판정과 1회 소비는 IndexController 가 담당한다.
|
||||
PortalUserEnums.RoleCode roleCode = user.getRoleCode();
|
||||
if (roleCode == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|
||||
|| roleCode == PortalUserEnums.RoleCode.ROLE_CORP_USER) {
|
||||
session.setAttribute("checkClientRegister", true);
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃 10분 고정 (콘솔 override 무관하게 물리=논리 단일화, CSRF 수명 포함)
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
logLoginSuccess(request, session, rawUsername);
|
||||
|
||||
String decisionToken = (String) session.getAttribute("decisionToken");
|
||||
if (decisionToken != null) {
|
||||
return contextPath + "/signup/decision_process";
|
||||
}
|
||||
|
||||
// 미인증 접근으로 저장해 둔 원래 요청 페이지로 복귀(1회용).
|
||||
// 단, 강제 유도 흐름(이메일 인증/휴면/비밀번호 변경 — session.redirectUrl 세팅)이 있으면
|
||||
// 그쪽이 우선이므로 복귀시키지 않고 기본 경로로 보낸다.
|
||||
String postLoginRedirect = (String) session.getAttribute(SESSION_POST_LOGIN_REDIRECT);
|
||||
session.removeAttribute(SESSION_POST_LOGIN_REDIRECT);
|
||||
if (postLoginRedirect != null && session.getAttribute("redirectUrl") == null) {
|
||||
return contextPath + postLoginRedirect;
|
||||
}
|
||||
return contextPath + "/";
|
||||
}
|
||||
|
||||
/** 후속 유도(이메일 인증/휴면/비밀번호 변경) 세션 플래그 세팅 */
|
||||
private void applyPostLoginState(PortalUser user, HttpSession session, String username, String contextPath) {
|
||||
if (isEmailVerificationRequired(user)) {
|
||||
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
|
||||
session.setAttribute("emailVerificationRequired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
|
||||
} else if (isDormantAccount(user)) {
|
||||
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
|
||||
session.setAttribute("dormantAccount", true);
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (user.getPasswordChangeDate() == null) {
|
||||
// 완화 정책: 현재 비밀번호 확인 단계를 제거했으므로 새 비밀번호 폼으로 바로 유도한다.
|
||||
applyPasswordChangeState(session,
|
||||
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
|
||||
*/
|
||||
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
|
||||
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
|
||||
portalPropertyService.getOrCreateProperty(
|
||||
PasswordEnforcementPolicy.PROPERTY_GROUP,
|
||||
PasswordEnforcementPolicy.PROPERTY_NAME,
|
||||
PasswordEnforcementPolicy.DEFAULT.name(),
|
||||
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.setAttribute("success", message);
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", redirectUrl);
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.ENFORCE) {
|
||||
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
|
||||
if (latestHistory.isPresent()) {
|
||||
LocalDateTime lastChangeDate = latestHistory.get().getChangeDate();
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(lastChangeDate);
|
||||
}
|
||||
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(user.getCreatedDate());
|
||||
}
|
||||
|
||||
private boolean isTemporaryPasswordLogin(PortalUser user) {
|
||||
Optional<MessageRequest> latestResetRequest = messageRequestRepository.findFirstByEmailAndMessageCodeOrderByRequestDateDesc(
|
||||
user.getLoginId(), MessageCode.USER_PASSWORD_RESET);
|
||||
|
||||
if (latestResetRequest.isPresent()) {
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
return !latestHistory.isPresent() || latestHistory.get().getChangeDate().isBefore(latestResetRequest.get().getRequestDate());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDormantAccount(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private boolean isEmailVerificationRequired(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private void logLoginSuccess(HttpServletRequest request, HttpSession session, String username) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN SUCCESS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
logMessage.append("Query String: ").append(request.getQueryString()).append("\n");
|
||||
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST HEADERS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
|
||||
java.util.Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.main.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
|
||||
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
|
||||
import com.eactive.apim.portal.apps.main.service.MainApiFacade;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.util.List;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -17,6 +21,7 @@ public class IndexController {
|
||||
|
||||
private final MainApiFacade mainApiFacade;
|
||||
private final IndexStatisticsService indexStatisticsService;
|
||||
private final AppServiceFacade appServiceFacade;
|
||||
|
||||
/**
|
||||
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
|
||||
@@ -29,7 +34,7 @@ public class IndexController {
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("/")
|
||||
public String index(Model model) {
|
||||
public String index(Model model, HttpSession session) {
|
||||
|
||||
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
|
||||
List<String> hashTags = mainApiFacade.getHashTags();
|
||||
@@ -39,9 +44,37 @@ public class IndexController {
|
||||
|
||||
addAttributesToModel(model, services, hashTags, statistics);
|
||||
|
||||
resolveClientRegisterNudge(model, session);
|
||||
|
||||
return "apps/main/index";
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인 사용자(관리자/개발자) 로그인 직후, 클라이언트(승인+요청중)가 하나도 없으면
|
||||
* 클라이언트 신규 신청 유도 팝업 노출 플래그를 세팅한다.
|
||||
*
|
||||
* <p>노출 시점은 {@code LoginFinalizer} 가 로그인 시 세팅한 {@code checkClientRegister}
|
||||
* 세션 마커로 통제한다. 마커는 홈 최초 진입에서 1회 소비하여, 이후 홈 재방문 시
|
||||
* 반복 노출되지 않게 한다. (역할 게이팅은 마커 세팅 측에서 이미 수행됨)</p>
|
||||
*/
|
||||
private void resolveClientRegisterNudge(Model model, HttpSession session) {
|
||||
if (session.getAttribute("checkClientRegister") == null) {
|
||||
return;
|
||||
}
|
||||
session.removeAttribute("checkClientRegister"); // 1회 소비
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user == null || user.getPortalOrg() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean hasClient = !appServiceFacade.getApikeyList(user.getPortalOrg()).isEmpty()
|
||||
|| !appServiceFacade.getPendingApiKeyList(user.getPortalOrg()).isEmpty();
|
||||
if (!hasClient) {
|
||||
model.addAttribute("needClientRegister", true);
|
||||
}
|
||||
}
|
||||
|
||||
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) {
|
||||
|
||||
model.addAttribute("services", apiServices);
|
||||
|
||||
+34
-44
@@ -1,30 +1,32 @@
|
||||
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.security.web.csrf.CsrfToken;
|
||||
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.
|
||||
* 세션 타이머/유휴 로그아웃 처리용 REST API.
|
||||
*
|
||||
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
|
||||
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
|
||||
* {@code DuplicateLoginService} 가 처리한다.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -33,32 +35,8 @@ import java.util.Optional;
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 상태 폴링 (인증 필요)
|
||||
*/
|
||||
@@ -110,21 +88,33 @@ public class SessionApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
||||
* 예: 192.168.240.178 → 192.168.***.178
|
||||
* 익명(비로그인) 페이지용 세션 keepalive ping.
|
||||
* 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어
|
||||
* 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다.
|
||||
* 세션이 없으면 새로 만들지 않는다.
|
||||
*/
|
||||
private String maskIpAddress(String ip) {
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
return "알 수 없음";
|
||||
@GetMapping("/ping")
|
||||
public ResponseEntity<Void> ping(HttpServletRequest request) {
|
||||
request.getSession(false);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망).
|
||||
* 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고,
|
||||
* 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository).
|
||||
* 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로
|
||||
* 로그인 페이지 안전망으로만 사용한다.
|
||||
*/
|
||||
@GetMapping("/csrf")
|
||||
public ResponseEntity<Map<String, String>> csrfToken(HttpServletRequest request) {
|
||||
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (token != null) {
|
||||
result.put("headerName", token.getHeaderName());
|
||||
result.put("parameterName", token.getParameterName());
|
||||
result.put("token", token.getToken());
|
||||
}
|
||||
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 "***";
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-19
@@ -2,6 +2,7 @@ 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.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,8 +21,9 @@ import java.util.Optional;
|
||||
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";
|
||||
|
||||
/** 세션 타임아웃(분) 고정값. application.yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일하게 유지한다. */
|
||||
public static final int SESSION_TIMEOUT_MINUTES = 10;
|
||||
|
||||
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||
@@ -64,7 +66,8 @@ public class UserSessionService {
|
||||
.forceLogout("N")
|
||||
.build();
|
||||
userSessionRepository.save(session);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}",
|
||||
StringMaskingUtil.maskLoginId(loginId), StringMaskingUtil.maskToken(sessionId), StringMaskingUtil.maskIpAddress(ipAddress));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +77,7 @@ public class UserSessionService {
|
||||
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
|
||||
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
|
||||
if (count > 0) {
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", StringMaskingUtil.maskLoginId(loginId), count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +89,7 @@ public class UserSessionService {
|
||||
public void forceLogoutAllSessions(String loginId) {
|
||||
int count = userSessionRepository.forceLogoutAllSessions(loginId);
|
||||
if (count > 0) {
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", StringMaskingUtil.maskLoginId(loginId), count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +110,7 @@ public class UserSessionService {
|
||||
public void removeSession(String sessionId) {
|
||||
if (userSessionRepository.existsById(sessionId)) {
|
||||
userSessionRepository.deleteById(sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", StringMaskingUtil.maskToken(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,21 +123,10 @@ public class UserSessionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
|
||||
* 세션 타임아웃(분). {@value #SESSION_TIMEOUT_MINUTES}분 고정 (DB property 관리 폐지).
|
||||
*/
|
||||
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);
|
||||
}
|
||||
return SESSION_TIMEOUT_MINUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-1
@@ -35,9 +35,14 @@ public class ApiStatisticsController {
|
||||
*/
|
||||
@GetMapping
|
||||
public String statisticsPage(Model model) {
|
||||
// 미로그인 접근은 사유를 노출하도록 reason=auth 로 유도(로그인 페이지 안내 배너)
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
PortalOrg org = getPortalOrg();
|
||||
if (org == null) {
|
||||
return "redirect:/login";
|
||||
// 로그인은 했으나 조직이 없는(권한 밖) 사용자는 홈으로
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
String orgId = org.getId();
|
||||
|
||||
+112
-15
@@ -1,6 +1,10 @@
|
||||
package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
|
||||
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;
|
||||
@@ -49,9 +53,18 @@ public class AccountController {
|
||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
||||
@PostMapping("/password/content-check")
|
||||
public ResponseEntity<Map<String, Boolean>> checkPasswordContent(@RequestParam String password) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
return ResponseEntity.ok(userFacade.checkPasswordContent(currentLoginId, password));
|
||||
}
|
||||
|
||||
@PostMapping("/password/confirm")
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
|
||||
@@ -60,19 +73,29 @@ public class AccountController {
|
||||
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
|
||||
}
|
||||
|
||||
@GetMapping("/change_password")
|
||||
public String showChangePasswordPage(Model model) {
|
||||
@GetMapping("/password/verify")
|
||||
public String showChangePasswordPage(Model model, HttpSession session) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// ENFORCE 강제 상태면 변경 페이지에 "변경/로그아웃" 강제 팝업을 띄운다.
|
||||
if (Boolean.TRUE.equals(session.getAttribute("pwEnforce"))) {
|
||||
model.addAttribute("forcedPasswordReset", true);
|
||||
}
|
||||
return "apps/mypage/passwordChangeEntry";
|
||||
}
|
||||
|
||||
@GetMapping("/new_password")
|
||||
public String showNewPasswordPage(Model model) {
|
||||
@GetMapping("/password/change")
|
||||
public String showNewPasswordPage(Model model, HttpSession session) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// 강제 변경(ENFORCE/만료) 진입 시 "변경/로그아웃" 강제 팝업을 띄운다.
|
||||
if (isPasswordEnforced(session)) {
|
||||
model.addAttribute("forcedPasswordReset", true);
|
||||
}
|
||||
// 반영 직전 2FA 필요 여부(강제 변경/2FA off 면 불필요) → 폼 JS 분기용
|
||||
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
@PostMapping("/verify_current_password")
|
||||
@PostMapping("/password/verify")
|
||||
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
||||
@@ -80,11 +103,11 @@ public class AccountController {
|
||||
return "apps/mypage/passwordChange";
|
||||
} else {
|
||||
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
return "redirect:/change_password";
|
||||
return "redirect:/password/verify";
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/mypage/change_new_password")
|
||||
@PostMapping("/password/change")
|
||||
public String updatePassword(@RequestParam String newPassword,
|
||||
@RequestParam String confirmPassword,
|
||||
HttpSession session,
|
||||
@@ -93,13 +116,23 @@ public class AccountController {
|
||||
RedirectAttributes redirectAttributes, Model model) {
|
||||
|
||||
try {
|
||||
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 폼으로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||
if (isPwChangeTwofaRequired(session)
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.PASSWORD_CHANGE)) {
|
||||
model.addAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
model.addAttribute("twofaRequired", true);
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
userFacade.updatePassword(currentLoginId, newPassword, confirmPassword);
|
||||
|
||||
// 비밀번호 만료 관련 세션 속성 제거
|
||||
// 비밀번호 만료/강제 관련 세션 속성 제거
|
||||
session.removeAttribute("passwordExpired");
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
session.removeAttribute("pwEnforce");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
@@ -110,22 +143,38 @@ public class AccountController {
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
|
||||
redirectAttributes.addFlashAttribute("success",
|
||||
"비밀번호가 성공적으로 변경되었습니다.<br>새 비밀번호로 다시 로그인해주세요.");
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
||||
logger.warn("비밀번호 변경 검증 실패: {}", e.getMessage());
|
||||
model.addAttribute("error", e.getMessage());
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// 2FA 통과권은 이미 소비됨 → 재제출 시 다시 2FA 를 요구하도록 플래그 유지
|
||||
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||
return "apps/mypage/passwordChange";
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("error", e.getMessage());
|
||||
// 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
|
||||
// 사용자에게는 시스템 예외 메시지를 노출하지 않고 일반 안내만 보여준다.
|
||||
logger.error("비밀번호 변경 처리 중 오류", e);
|
||||
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/mypage")
|
||||
public ModelAndView mypage() {
|
||||
public ModelAndView mypage(HttpSession session) {
|
||||
// 완화된 step-up(PASSWORD 레벨): 통과권이 없으면 현재 비밀번호 확인 페이지로 유도한다.
|
||||
// (내 정보 변경은 2FA 대신 비밀번호 재확인만 요구)
|
||||
if (isMypageStepUpActive()
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.MYPAGE)) {
|
||||
return new ModelAndView("redirect:/auth/stepup/password?returnUrl=" + StepUpProtectedPaths.MYPAGE);
|
||||
}
|
||||
|
||||
ModelAndView mav = new ModelAndView();
|
||||
|
||||
try {
|
||||
@@ -141,6 +190,9 @@ public class AccountController {
|
||||
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
|
||||
mav.addObject("user", user);
|
||||
|
||||
// 회원 탈퇴 팝업: 제출 전에 2FA 팝업을 띄울지 여부
|
||||
mav.addObject("withdrawTwofaRequired", isWithdrawTwofaRequired());
|
||||
|
||||
// ROLE_USER인 경우 초대 여부 확인
|
||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
java.util.Optional<UserInvitation> pendingInvitation =
|
||||
@@ -238,6 +290,8 @@ public class AccountController {
|
||||
return "redirect:/mypage";
|
||||
} finally {
|
||||
cleanupAuthSession(session);
|
||||
// 수정 저장 후 redirect:/mypage 로 되돌아갈 때 step-up 재요구를 막는다(1회 왕복 한정).
|
||||
twoFactorService.grantStepUpPass(session, StepUpProtectedPaths.MYPAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +304,29 @@ public class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 내 정보({@code /mypage}) 완화 step-up(비밀번호 재확인)이 현재 활성인지 */
|
||||
private boolean isMypageStepUpActive() {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.MYPAGE);
|
||||
}
|
||||
|
||||
/** 비밀번호 강제 변경 상태(ENFORCE/만료)인지 */
|
||||
private boolean isPasswordEnforced(HttpSession session) {
|
||||
return Boolean.TRUE.equals(session.getAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR))
|
||||
|| Boolean.TRUE.equals(session.getAttribute("passwordExpired"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 반영 직전에 2FA 를 요구할지 여부.
|
||||
* 전체/지점 step-up 스위치가 켜져 있고 <b>강제 변경 상태가 아닐 때만</b> 요구한다
|
||||
* (강제/만료 변경은 기존대로 2FA 없이 진행).
|
||||
*/
|
||||
private boolean isPwChangeTwofaRequired(HttpSession session) {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.PASSWORD_CHANGE)
|
||||
&& !isPasswordEnforced(session);
|
||||
}
|
||||
|
||||
@GetMapping("/mypage/org-transfer")
|
||||
public String showOrgTransferPage(Model model) {
|
||||
try {
|
||||
@@ -335,21 +412,35 @@ public class AccountController {
|
||||
|
||||
@PostMapping("/withdraw")
|
||||
public String processWithdrawal(
|
||||
@RequestParam(value = "withdrawReason", required = false) String withdrawReason,
|
||||
HttpSession session,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
try {
|
||||
// 탈퇴 사유 필수
|
||||
if (withdrawReason == null || withdrawReason.trim().isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "탈퇴 사유를 입력해 주세요.");
|
||||
return "redirect:/mypage";
|
||||
}
|
||||
|
||||
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않는다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||
if (isWithdrawTwofaRequired()
|
||||
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.WITHDRAW)) {
|
||||
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||
return "redirect:/mypage";
|
||||
}
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
// 회원 탈퇴 처리
|
||||
if (currentUser != null) {
|
||||
userFacade.withdrawUser(currentUser.getId());
|
||||
userFacade.withdrawUser(currentUser.getId(), withdrawReason.trim());
|
||||
}
|
||||
|
||||
session.invalidate();
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 신청이 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 됩니다.");
|
||||
redirectAttributes.addFlashAttribute("success", "회원 탈퇴가 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 되었습니다.");
|
||||
return "redirect:/";
|
||||
} catch (IllegalArgumentException e) {
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
@@ -357,6 +448,12 @@ public class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 회원 탈퇴({@code /withdraw}) 반영 직전 2FA 를 요구할지 여부 */
|
||||
private boolean isWithdrawTwofaRequired() {
|
||||
return twoFactorProperties.isStepUpEnabled()
|
||||
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.WITHDRAW);
|
||||
}
|
||||
|
||||
@GetMapping("/mypage/verification-email")
|
||||
public String showVerificationEmailPage(Model model) {
|
||||
try {
|
||||
@@ -364,7 +461,7 @@ public class AccountController {
|
||||
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
|
||||
|
||||
if (currentUser == null) {
|
||||
return "redirect:/login";
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
|
||||
// 사용자 이메일 주소를 모델에 추가
|
||||
|
||||
+15
-2
@@ -11,6 +11,8 @@ import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/signup/portalOrg")
|
||||
@RequiredArgsConstructor
|
||||
@@ -39,17 +41,28 @@ public class OrgRegisterController {
|
||||
@RequestParam("registrationScenario") String scenario,
|
||||
@ModelAttribute PortalOrgRegistrationDTO orgDTO,
|
||||
@ModelAttribute UserAgreementDTO agreementDTO,
|
||||
HttpSession session,
|
||||
Model model) {
|
||||
|
||||
try {
|
||||
ValidationResponse response;
|
||||
|
||||
switch (scenario) {
|
||||
case "new":
|
||||
case "new": {
|
||||
// 가입 폼에서 이메일 인증을 마쳤는지 세션으로 검증 (개인 경로와 동일, 클라 hidden 불신)
|
||||
String verifiedEmail = (String) session.getAttribute("signupVerifiedEmail");
|
||||
boolean emailVerified = verifiedEmail != null
|
||||
&& verifiedEmail.equalsIgnoreCase(orgDTO.getLoginId());
|
||||
response = orgRegisterFacade.registerNewOrgUser(
|
||||
orgDTO,
|
||||
agreementDTO);
|
||||
agreementDTO,
|
||||
emailVerified);
|
||||
if (response.isValid() && emailVerified) {
|
||||
session.removeAttribute("signupVerifiedEmail");
|
||||
session.removeAttribute("signupEmailPending");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "retain":
|
||||
response = orgRegisterFacade.convertToOrgUser(
|
||||
|
||||
+2
-2
@@ -74,11 +74,11 @@ 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", "이용자로 변경되었습니다.");
|
||||
return new ResponseDTO(200, "SUCCESS", "개발자로 변경되었습니다.");
|
||||
}
|
||||
|
||||
// 소속 제외 -> 개인이용자로 전환
|
||||
|
||||
+58
-1
@@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
|
||||
@@ -114,6 +115,7 @@ public class UserRegisterController {
|
||||
@Valid @ModelAttribute("portalUser") PortalUserRegistrationDTO portalUserRegistrationDTO,
|
||||
BindingResult bindingResult,
|
||||
HttpSession session,
|
||||
HttpServletRequest request,
|
||||
RedirectAttributes redirectAttributes,
|
||||
Model model) {
|
||||
try {
|
||||
@@ -140,9 +142,20 @@ public class UserRegisterController {
|
||||
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
||||
session.removeAttribute("invitationToken");
|
||||
|
||||
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
|
||||
// 개인 가입자 처리 분기
|
||||
if (invitationToken == null) {
|
||||
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
|
||||
|
||||
// 이메일 인증을 마쳐 ACTIVE 로 저장된 경우 → 자동 로그인(2차 인증 없이) 후 가입 완료 페이지 노출
|
||||
// (완료 페이지의 '홈으로' 버튼으로 로그인 상태 그대로 메인 이동)
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.ACTIVE.equals(registered.get().getUserStatus())) {
|
||||
portalUserAuthService.autoLoginAfterSignup(registered.get(), request);
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return "redirect:/signup/complete";
|
||||
}
|
||||
|
||||
// 이메일 미인증(READY) → 회원가입 직후 이메일 인증 단계로 이동(기존 흐름 유지)
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
|
||||
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
|
||||
@@ -197,6 +210,50 @@ public class UserRegisterController {
|
||||
return "apps/register/signupVerificationEmail";
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 내 이메일 인증코드 발송. 형식·중복 선검증 후 발송하고, 세션에 대상 이메일을 저장한다.
|
||||
* (가입 완료 전, 폼에서 인라인으로 호출)
|
||||
*/
|
||||
@PostMapping("/signup/email-code/send")
|
||||
public ResponseEntity<ValidationResponse> sendSignupFormEmailCode(@RequestParam String email,
|
||||
HttpSession session) {
|
||||
String normalized = email != null ? email.trim().toLowerCase() : null;
|
||||
|
||||
// 형식 + 중복 선검증 (중복 이메일을 인증까지 마친 뒤 가입 단계에서 거절되는 것을 방지)
|
||||
ValidationResponse check = userRegisterFacade.handleCheckNewEmail(normalized);
|
||||
if (!check.isValid()) {
|
||||
return ResponseEntity.ok(check);
|
||||
}
|
||||
|
||||
ValidationResponse response = authFacade.requestAuth(normalized, "EMAIL");
|
||||
if (response.isValid()) {
|
||||
session.setAttribute("signupEmailPending", normalized);
|
||||
session.removeAttribute("signupVerifiedEmail");
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 내 이메일 인증코드 검증. 성공 시 세션에 인증 완료 이메일을 저장한다.
|
||||
* (가입 제출 시 서버가 이 값과 DTO 이메일 일치를 재검증한다)
|
||||
*/
|
||||
@PostMapping("/signup/email-code/verify")
|
||||
public ResponseEntity<ValidationResponse> verifySignupFormEmailCode(@RequestParam String email,
|
||||
@RequestParam String code,
|
||||
HttpSession session) {
|
||||
String normalized = email != null ? email.trim().toLowerCase() : null;
|
||||
String pending = (String) session.getAttribute("signupEmailPending");
|
||||
if (pending == null || !pending.equalsIgnoreCase(normalized)) {
|
||||
return ResponseEntity.ok(new ValidationResponse(false, "인증 요청된 이메일과 일치하지 않습니다."));
|
||||
}
|
||||
|
||||
ValidationResponse response = authFacade.verifyAuthNumber(normalized, code);
|
||||
if (response.isValid()) {
|
||||
session.setAttribute("signupVerifiedEmail", normalized);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.apps.user.dto;
|
||||
import com.eactive.apim.portal.common.validator.AuthNumberMatch;
|
||||
import com.eactive.apim.portal.common.validator.CellPhone;
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotEmpty;
|
||||
@@ -12,7 +12,7 @@ import org.hibernate.validator.constraints.NotEmpty;
|
||||
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
public class PortalUserRegistrationDTO {
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import com.eactive.apim.portal.common.validator.UniqueId;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserStatus;
|
||||
import lombok.Data;
|
||||
@@ -13,7 +13,7 @@ import java.io.Serializable;
|
||||
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
@PasswordRule(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
public class UserRegisterDTO implements Serializable {
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.user.facade;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -13,6 +14,7 @@ import org.slf4j.LoggerFactory;
|
||||
public class AuthFacadeImpl implements AuthFacade {
|
||||
|
||||
private final AuthNumberService authNumberService;
|
||||
private final AuthNoticeProperties authNoticeProperties;
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthFacadeImpl.class);
|
||||
|
||||
|
||||
@@ -44,7 +46,10 @@ public class AuthFacadeImpl implements AuthFacade {
|
||||
String generatedAuthNumber = authNumberService.sendRequestAuthNumber(recipientKey, msgType);
|
||||
response.setValid(true);
|
||||
response.setMessage("인증번호를 발송하였습니다.");
|
||||
response.setAuthNumber(generatedAuthNumber); // 테스트 환경에서 인증번호 표시용
|
||||
// 테스트 환경(PTL_PROPERTY auth.test-notice.enabled=true, prod 제외)에서만 인증번호를 응답에 노출
|
||||
if (authNoticeProperties.isTestNoticeEnabled()) {
|
||||
response.setAuthNumber(generatedAuthNumber);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
response.setValid(false);
|
||||
response.setMessage(e.getMessage());
|
||||
|
||||
@@ -7,10 +7,11 @@ import org.springframework.http.ResponseEntity;
|
||||
|
||||
|
||||
public interface OrgRegisterFacade {
|
||||
// 신규 법인 회원 등록
|
||||
// 신규 법인 회원 등록 (emailVerified: 가입 폼에서 이메일 인증 완료 시 true → 바로 ACTIVE)
|
||||
ValidationResponse registerNewOrgUser(
|
||||
PortalOrgRegistrationDTO orgDTO,
|
||||
UserAgreementDTO agreementDTO) ;
|
||||
UserAgreementDTO agreementDTO,
|
||||
boolean emailVerified) ;
|
||||
|
||||
// 기존 회원의 법인 전환
|
||||
ValidationResponse convertToOrgUser(
|
||||
|
||||
@@ -54,6 +54,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
private final FileService fileService;
|
||||
private final BasicValidationService validationService;
|
||||
private final UserRegistrationValidationService userRegistrationValidationService;
|
||||
private final com.eactive.apim.portal.apps.user.validator.PasswordValidator passwordValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final ApprovalService approvalService;
|
||||
@@ -64,7 +65,8 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
@Transactional
|
||||
public ValidationResponse registerNewOrgUser(
|
||||
PortalOrgRegistrationDTO orgDTO,
|
||||
UserAgreementDTO agreementDTO) {
|
||||
UserAgreementDTO agreementDTO,
|
||||
boolean emailVerified) {
|
||||
|
||||
if (!agreementValidator.isAgreementAccepted(agreementDTO)) {
|
||||
return new ValidationResponse(false, "약관에 모두 동의해야 합니다.");
|
||||
@@ -74,20 +76,35 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
return new ValidationResponse(false, "입력 정보가 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
// 2025.10.20 - 휴대폰 번호 중복 무시
|
||||
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(orgDTO.getUserName(), orgDTO.getMobileNumber());
|
||||
// 개인 가입(@Valid @PasswordRule)과 달리 법인 가입은 컨트롤러 바인딩 검증이 없어
|
||||
// 여기서 서버 측 비밀번호 규칙을 직접 검증한다 (retain/change 시나리오는 기존 비밀번호 유지라 제외)
|
||||
if (!passwordValidator.isValidPassword(orgDTO.getPassword(), orgDTO.getLoginId(), orgDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false,
|
||||
"비밀번호는 영문/숫자/특수문자 포함 8~50자이며, 아이디·휴대전화 번호, 3자리 이상 연속·반복 문자는 사용할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (orgDTO.getConfirmPassword() == null || !orgDTO.getConfirmPassword().equals(orgDTO.getPassword())) {
|
||||
return new ValidationResponse(false, "비밀번호와 비밀번호 확인이 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId());
|
||||
|
||||
if(existingUser.isPresent()) {
|
||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
}
|
||||
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(orgDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles());
|
||||
if (uploadedFile == null) {
|
||||
return new ValidationResponse(false, "첨부파일이 올바르지 않습니다.");
|
||||
}
|
||||
return registerNewCorporateUser(orgDTO, uploadedFile);
|
||||
return registerNewCorporateUser(orgDTO, uploadedFile, emailVerified);
|
||||
} catch (IllegalArgumentException | IOException e) {
|
||||
return new ValidationResponse(false, e.getMessage());
|
||||
}
|
||||
@@ -179,7 +196,8 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
// 신규 법인 사용자 등록 메서드
|
||||
private ValidationResponse registerNewCorporateUser(
|
||||
PortalOrgRegistrationDTO orgDTO,
|
||||
FileInfo uploadedFile) {
|
||||
FileInfo uploadedFile,
|
||||
boolean emailVerified) {
|
||||
|
||||
// 사업자등록번호 중복 체크 (이중 방어)
|
||||
if (portalOrgService.existsByCompRegNo(orgDTO.getCompRegNo())) {
|
||||
@@ -190,7 +208,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
PortalOrg newOrg = portalOrgService.registerOrgFromDTOWithFile(orgDTO, uploadedFile);
|
||||
|
||||
// 사용자 생성 및 기관 연결
|
||||
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate");
|
||||
PortalUser newUser = portalUserService.createUserWithOrg(orgDTO, newOrg, "corporate", emailVerified);
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ public interface UserFacade {
|
||||
|
||||
void updatePassword(String loginId, String newPassword, String confirmPassword);
|
||||
|
||||
/** 비밀번호에 아이디/휴대전화 번호가 포함되는지 라이브 체크용 판정 (키: idIncluded, mobileIncluded) */
|
||||
java.util.Map<String, Boolean> checkPasswordContent(String loginId, String password);
|
||||
|
||||
void updateUser(PortalUserDTO portalUserDTO);
|
||||
|
||||
void updateCorporateManager(PortalUserDTO portalUserDTO);
|
||||
|
||||
void withdrawUser(String userId);
|
||||
void withdrawUser(String userId, String withdrawalReason);
|
||||
|
||||
void activateUserByEmail(String email);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.facade;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.apps.user.service.PasswordService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
||||
@@ -59,6 +60,12 @@ public class UserFacadeImpl implements UserFacade {
|
||||
messageHandlerService.publishEvent(UserPasswordChangedEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
|
||||
return passwordService.checkPasswordContent(loginId, password);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateUser(PortalUserDTO portalUserDTO) {
|
||||
@@ -71,7 +78,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
updateUserBasicInfo(user, portalUserDTO);
|
||||
portalUserService.updateUser(user);
|
||||
|
||||
log.info("사용자 정보 업데이트 완료: {}", user.getLoginId());
|
||||
log.info("사용자 정보 업데이트 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,7 +105,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
portalUserService.updateUser(user);
|
||||
portalOrgService.updateOrg(org);
|
||||
|
||||
log.info("법인 관리자 정보 업데이트 완료: {}", user.getLoginId());
|
||||
log.info("법인 관리자 정보 업데이트 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
// 사용자 기본 업데이트
|
||||
@@ -130,13 +137,13 @@ public class UserFacadeImpl implements UserFacade {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void withdrawUser(String userId) {
|
||||
public void withdrawUser(String userId, String withdrawalReason) {
|
||||
PortalUser user = portalUserService.findById(userId);
|
||||
|
||||
// 법인 관리자 탈퇴 제한
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
|
||||
throw new IllegalArgumentException("법인 관리자권한을 다른 이용자에게 위임하신 후 탈퇴가 가능합니다.");
|
||||
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +153,8 @@ public class UserFacadeImpl implements UserFacade {
|
||||
// 메시지 요청정보 삭제
|
||||
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
|
||||
|
||||
portalUserService.deleteUser(user);
|
||||
log.info("회원 탈퇴 처리 완료: {}", user.getLoginId());
|
||||
portalUserService.deleteUser(user, withdrawalReason);
|
||||
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트 유효성 확인
|
||||
@@ -215,6 +222,6 @@ public class UserFacadeImpl implements UserFacade {
|
||||
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
|
||||
portalUserService.save(user);
|
||||
|
||||
log.info("사용자 이메일 인증 완료 - 활성화: {}", email);
|
||||
log.info("사용자 이메일 인증 완료 - 활성화: {}", StringMaskingUtil.maskEmail(email));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ 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.apps.user.service.UserRoleHistoryService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRoleHistoryService.ChangeType;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
@@ -59,6 +61,7 @@ public class UserManFacade {
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final UserRoleHistoryService userRoleHistoryService;
|
||||
|
||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||
return portalUserRepository
|
||||
@@ -134,7 +137,7 @@ public class UserManFacade {
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
if (user.getPortalOrg() != null && !user.getRoleCode().equals(RoleCode.ROLE_USER)) {
|
||||
throw new IllegalArgumentException("기관에 등록된 이용자 입니다.");
|
||||
throw new IllegalArgumentException("기관에 등록된 개발자 입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +243,9 @@ public class UserManFacade {
|
||||
PortalUser currentUser = portalUserRepository.findById(portalAuthenticatedUser.getId())
|
||||
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다. "));
|
||||
|
||||
RoleCode targetBefore = targetUser.getRoleCode();
|
||||
RoleCode currentBefore = currentUser.getRoleCode();
|
||||
|
||||
// 3. Assign role code ROLE_CORP_MANAGER to target User
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
|
||||
|
||||
@@ -249,6 +255,10 @@ public class UserManFacade {
|
||||
portalUserRepository.save(targetUser);
|
||||
portalUserRepository.save(currentUser);
|
||||
|
||||
// 역할 변경 감사 이력: 대상 위임 + 본인 회수
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), targetBefore, RoleCode.ROLE_CORP_MANAGER, ChangeType.MANAGER_ASSIGN);
|
||||
userRoleHistoryService.record(currentUser.getLoginId(), currentBefore, RoleCode.ROLE_CORP_USER, ChangeType.MANAGER_REVOKE);
|
||||
|
||||
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
@@ -264,7 +274,7 @@ public class UserManFacade {
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인 관리자 권한 회수 (관리자 → 이용자).
|
||||
* 법인 관리자 권한 회수 (관리자 → 개발자).
|
||||
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
|
||||
*/
|
||||
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
@@ -279,12 +289,16 @@ public class UserManFacade {
|
||||
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
|
||||
}
|
||||
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
|
||||
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
|
||||
throw new IllegalArgumentException("관리자만 개발자로 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
RoleCode revokeBefore = targetUser.getRoleCode();
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 역할 변경 감사 이력: 관리자 → 개발자
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), revokeBefore, RoleCode.ROLE_CORP_USER, ChangeType.MANAGER_REVOKE);
|
||||
|
||||
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
@@ -305,10 +319,14 @@ public class UserManFacade {
|
||||
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
|
||||
}
|
||||
|
||||
RoleCode removeBefore = targetUser.getRoleCode();
|
||||
targetUser.setPortalOrg(null);
|
||||
targetUser.setRoleCode(RoleCode.ROLE_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 역할 변경 감사 이력: 소속 제외 → 개인 전환
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), removeBefore, RoleCode.ROLE_USER, ChangeType.ORG_REMOVE);
|
||||
|
||||
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
@Override
|
||||
public ValidationResponse checkPassword(String password, String loginId, String mobileNumber) {
|
||||
boolean isValid = passwordValidator.isValidPassword(password, loginId, mobileNumber);
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~20자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~50자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
return new ValidationResponse(isValid, message);
|
||||
}
|
||||
|
||||
@@ -143,11 +143,20 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
// 가입 폼에서 이메일 인증을 마쳤는지 확인(세션 signupVerifiedEmail 이 가입 이메일과 일치)
|
||||
String verifiedEmail = (String) session.getAttribute("signupVerifiedEmail");
|
||||
boolean emailVerified = verifiedEmail != null
|
||||
&& verifiedEmail.equalsIgnoreCase(registrationDTO.getLoginId());
|
||||
|
||||
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal", emailVerified);
|
||||
if (newUser == null) {
|
||||
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
|
||||
}
|
||||
if (emailVerified) {
|
||||
session.removeAttribute("signupVerifiedEmail");
|
||||
session.removeAttribute("signupEmailPending");
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
@@ -178,7 +187,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(),
|
||||
com.eactive.apim.portal.common.util.PhoneNumberUtil.normalize(registrationDTO.getMobileNumber()));
|
||||
|
||||
if(existingUser != null) {
|
||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
@@ -43,6 +44,8 @@ public class PasswordService {
|
||||
// 새 비밀번호 설정
|
||||
String newPasswordHash = passwordEncoder.encode(newPassword);
|
||||
user.setPasswordHash(newPasswordHash);
|
||||
// 변경일 기록 → 재설정 강제(null 트리거) 해제
|
||||
user.setPasswordChangeDate(LocalDateTime.now());
|
||||
portalUserRepository.save(user);
|
||||
|
||||
savePasswordHistory(user.getId(), newPasswordHash);
|
||||
@@ -69,6 +72,19 @@ public class PasswordService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호에 본인 아이디(local part)/휴대전화 세그먼트가 포함되는지 판정 — 변경 화면 라이브 체크용 */
|
||||
@Transactional(readOnly = true)
|
||||
public java.util.HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
|
||||
PortalUser user = portalUserRepository.findByLoginId(loginId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
||||
java.util.HashMap<String, Boolean> result = new java.util.HashMap<>();
|
||||
result.put("idIncluded",
|
||||
PasswordRuleValidator.containsLoginIdLocalPart(password, user.getLoginId()));
|
||||
result.put("mobileIncluded",
|
||||
PasswordRuleValidator.containsMobileSegment(password, user.getMobileNumber()));
|
||||
return result;
|
||||
}
|
||||
|
||||
private void checkPasswordHistory(String userId, String newPassword) {
|
||||
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
||||
|
||||
|
||||
+38
-11
@@ -1,14 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
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.djb.menu.PortalRolesProperties;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
@@ -48,11 +50,12 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserMapper portalUserMapper;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalRolesProperties portalRolesProperties;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
||||
@@ -73,7 +76,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
*/
|
||||
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
List<String> roles = portalRolesProperties.getAuthorities(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
@@ -101,6 +104,28 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
SecurityContextHolder.getContext().setAuthentication(newAuth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 직후 자동 로그인. formLogin 을 경유하지 않으므로 세션 고정 방어(changeSessionId)를
|
||||
* 수동 수행하고, SuccessHandler 와 동일한 후처리({@link LoginFinalizer})로 세션 등록·감사 기록을 맞춘다.
|
||||
* SuccessHandler 를 타지 않으므로 로그인 2FA 는 자연히 건너뛴다.
|
||||
*
|
||||
* @return 이동 대상 URL
|
||||
*/
|
||||
@Transactional
|
||||
public String autoLoginAfterSignup(PortalUser user, javax.servlet.http.HttpServletRequest request) {
|
||||
// 세션 고정 공격 방어 (form login 미경유 → 수동)
|
||||
request.changeSessionId();
|
||||
|
||||
PortalAuthenticatedUser authUser = buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
// 세션 등록 / 감사 성공 기록(SIGNUP_AUTO) / 타임아웃 설정 재사용
|
||||
return loginFinalizer.finalizeLogin(user, user.getLoginId(), request, LoginType.SIGNUP_AUTO);
|
||||
}
|
||||
|
||||
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
|
||||
try {
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
@@ -111,12 +136,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
}
|
||||
|
||||
return users.stream().map(user -> {
|
||||
PortalUserDTO dto = portalUserMapper.toDTO(user);
|
||||
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
|
||||
dto.setLoginId(null);
|
||||
return dto;
|
||||
}).collect(Collectors.toList());
|
||||
// 아이디 찾기: 이름+휴대폰 본인인증을 마친 사용자에게 보여주는 결과이므로
|
||||
// 이메일(아이디)을 마스킹 없이 그대로 반환한다.
|
||||
return users.stream().map(portalUserMapper::toDTO).collect(Collectors.toList());
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
throw e;
|
||||
@@ -130,6 +152,8 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
}
|
||||
|
||||
public void resetPassword(String loginId, String userName, String mobileNumber) {
|
||||
// 입력 그룹핑이 저장 정규형과 달라도 매칭되도록 조회 전 정규화 (암호화 컬럼 등가 비교)
|
||||
mobileNumber = PhoneNumberUtil.normalize(mobileNumber);
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
|
||||
}
|
||||
@@ -139,6 +163,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
|
||||
String tempPassword = EncryptionUtil.generateNewPassword();
|
||||
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
||||
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
||||
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
||||
portalUser.setPasswordChangeDate(null);
|
||||
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
|
||||
portalUser.setAccountLockYn("N");
|
||||
}
|
||||
@@ -161,7 +188,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
@Transactional
|
||||
public void reactivateDormantAccount(String loginId, String password, String mobileNumber) {
|
||||
try {
|
||||
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, mobileNumber)
|
||||
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, PhoneNumberUtil.normalize(mobileNumber))
|
||||
.orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
||||
|
||||
if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.user.entity.UserLog;
|
||||
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -17,23 +19,33 @@ public class PortalUserLogService {
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId) {
|
||||
logSuccess(userId, ip, sessionId, LoginType.NORMAL);
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(true);
|
||||
log.setLoginType(loginType != null ? loginType.name() : null);
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId) {
|
||||
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(false);
|
||||
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,14 @@ public class PortalUserService {
|
||||
|
||||
// 승인 대기 상태.
|
||||
public PortalUser registerActiveUser(PortalUserRegistrationDTO newUserDTO, String registrationType) {
|
||||
return registerActiveUser(newUserDTO, registrationType, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param emailVerified 가입 폼에서 이메일 인증을 이미 완료했으면 true → 바로 ACTIVE 로 저장
|
||||
* (가입 후 별도 이메일 인증 단계를 건너뛴다)
|
||||
*/
|
||||
public PortalUser registerActiveUser(PortalUserRegistrationDTO newUserDTO, String registrationType, boolean emailVerified) {
|
||||
PortalUser newUser = new PortalUser();
|
||||
mapDtoToEntity(newUser, newUserDTO);
|
||||
setUserProperties(newUser);
|
||||
@@ -149,8 +157,9 @@ public class PortalUserService {
|
||||
|
||||
newUser.setUserStatus(UserStatus.READY);
|
||||
|
||||
// 이메일 인증 기능 비활성화 시 바로 활성화 처리
|
||||
if ("true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
|
||||
// 이메일 인증 기능 비활성화 시, 또는 가입 폼에서 이미 인증을 마친 경우 바로 활성화 처리
|
||||
if (emailVerified
|
||||
|| "true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
|
||||
newUser.setUserStatus(UserStatus.ACTIVE);
|
||||
}
|
||||
newUser.setApprovalStatus(ApprovalStatus.COMPLETED);
|
||||
@@ -166,11 +175,26 @@ public class PortalUserService {
|
||||
}
|
||||
|
||||
public PortalUser createUserWithOrg(PortalUserRegistrationDTO userDTO, PortalOrg org, String registrationType) {
|
||||
return createUserWithOrg(userDTO, org, registrationType, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param emailVerified 가입 폼에서 이메일 인증을 이미 완료했으면 true → 바로 ACTIVE 로 저장
|
||||
* (개인 경로 registerActiveUser 와 동일. 법인 신규가입 경로에서 사용)
|
||||
*/
|
||||
public PortalUser createUserWithOrg(PortalUserRegistrationDTO userDTO, PortalOrg org, String registrationType, boolean emailVerified) {
|
||||
PortalUser newUser = new PortalUser();
|
||||
mapDtoToEntity(newUser, userDTO);
|
||||
newUser.setPortalOrg(org);
|
||||
setUserProperties(newUser);
|
||||
newUser.setUserStatus(UserStatus.READY);
|
||||
|
||||
// 이메일 인증 기능 비활성화 시, 또는 가입 폼에서 이미 인증을 마친 경우 바로 활성화 처리
|
||||
Map<String, String> propertyMap = portalPropertyService.getPortalPropertiesAsMap("Portal");
|
||||
if (emailVerified
|
||||
|| "true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
|
||||
newUser.setUserStatus(UserStatus.ACTIVE);
|
||||
}
|
||||
newUser.setApprovalStatus(ApprovalStatus.COMPLETED);
|
||||
setUserRole(newUser, registrationType);
|
||||
return portalUserRepository.save(newUser);
|
||||
@@ -184,6 +208,8 @@ public class PortalUserService {
|
||||
user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
|
||||
user.setMobileNumber(dto.getMobileNumber());
|
||||
user.setEmailAddr(normalizedEmail);
|
||||
// 가입 시점을 비밀번호 변경일로 기록 → 신규 가입자는 재설정 강제 대상에서 제외된다.
|
||||
user.setPasswordChangeDate(java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,7 +280,7 @@ public class PortalUserService {
|
||||
return portalUserRepository.save(user);
|
||||
}
|
||||
|
||||
public void deleteUser(PortalUser user) {
|
||||
public void deleteUser(PortalUser user, String withdrawalReason) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
|
||||
|
||||
@@ -264,6 +290,12 @@ public class PortalUserService {
|
||||
user.setMobileNumber("");
|
||||
user.setPasswordHash("");
|
||||
|
||||
// 탈퇴 사유 보존 (컬럼 길이 200 초과분은 잘라 저장)
|
||||
if (withdrawalReason != null && withdrawalReason.length() > 200) {
|
||||
withdrawalReason = withdrawalReason.substring(0, 200);
|
||||
}
|
||||
user.setWithdrawalReason(withdrawalReason);
|
||||
|
||||
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
|
||||
|
||||
portalUserRepository.save(user);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserRoleHistory;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 사용자 역할(권한) 변경 감사 이력 기록 서비스.
|
||||
*
|
||||
* <p>법인 관리자 위임/회수, 소속 제외 등 역할 변경 이벤트를 {@code ptl_user_role_history} 에 남긴다.
|
||||
* 변경 수행자(changedBy)는 현재 인증된 관리자의 loginId 로 기록한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoleHistoryService {
|
||||
|
||||
/** 역할 변경 유형. */
|
||||
public enum ChangeType {
|
||||
MANAGER_ASSIGN, // 관리자 권한 위임
|
||||
MANAGER_REVOKE, // 관리자 권한 회수
|
||||
ORG_REMOVE // 소속 제외(개인으로 전환)
|
||||
}
|
||||
|
||||
private final UserRoleHistoryRepository userRoleHistoryRepository;
|
||||
|
||||
/**
|
||||
* 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록
|
||||
* 호출부에서 예외를 전파하지 않는다(내부에서 로깅만).
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) {
|
||||
try {
|
||||
String actor = SecurityUtil.getCurrentLoginId();
|
||||
if (actor == null || actor.isEmpty()) {
|
||||
actor = "SYSTEM";
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
UserRoleHistory history = new UserRoleHistory();
|
||||
history.setUserId(targetLoginId);
|
||||
history.setBeforeRole(before != null ? before.name() : null);
|
||||
history.setAfterRole(after != null ? after.name() : null);
|
||||
history.setChangeType(changeType.name());
|
||||
history.setChangedBy(actor);
|
||||
history.setChangeDate(now);
|
||||
history.setCreatedBy(actor);
|
||||
history.setCreatedDate(now);
|
||||
|
||||
userRoleHistoryRepository.save(history);
|
||||
} catch (Exception e) {
|
||||
log.error("역할 변경 이력 기록 실패 - target: {}, type: {}", targetLoginId, changeType, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.user.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@@ -17,13 +16,13 @@ public class PasswordValidator {
|
||||
}
|
||||
|
||||
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
||||
PasswordRuleForDjbankValidator validator = new PasswordRuleForDjbankValidator();
|
||||
PasswordRuleValidator validator = new PasswordRuleValidator();
|
||||
return validator.isValid(password, loginId, mobileNumber);
|
||||
}
|
||||
|
||||
private boolean isValidLengthAndCharacters(String password) {
|
||||
final int MIN = 8;
|
||||
final int MAX = 20;
|
||||
final int MAX = 50;
|
||||
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
|
||||
return password.matches(REGEX);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.core.env.Profiles;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.apps.auth.AuthNoticeProperties;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
@@ -31,6 +32,9 @@ public class GlobalControllerAdvice {
|
||||
@Autowired
|
||||
private PortalPropertyService portalPropertyService;
|
||||
|
||||
@Autowired
|
||||
private AuthNoticeProperties authNoticeProperties;
|
||||
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
@@ -48,12 +52,12 @@ public class GlobalControllerAdvice {
|
||||
|
||||
@ModelAttribute("showTestAuthNotice")
|
||||
public boolean showTestAuthNotice() {
|
||||
return portalProperties.isTestAuthNoticeEnabled();
|
||||
return authNoticeProperties.isTestNoticeEnabled();
|
||||
}
|
||||
|
||||
@ModelAttribute("testAuthNumber")
|
||||
public String getTestAuthNumber() {
|
||||
if (portalProperties.isTestAuthNoticeEnabled()) {
|
||||
if (authNoticeProperties.isTestNoticeEnabled()) {
|
||||
String authVirtualCode = portalProperties.getAuthVirtualCode();
|
||||
// 고정 인증번호가 있으면 반환, 없으면 "random" 표시
|
||||
return (authVirtualCode != null && !authVirtualCode.isEmpty()) ? authVirtualCode : "random";
|
||||
@@ -62,7 +66,7 @@ public class GlobalControllerAdvice {
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
|
||||
*/
|
||||
@ModelAttribute("sessionTimeoutMinutes")
|
||||
public int sessionTimeoutMinutes() {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.eactive.apim.portal.common.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@PasswordRuleForKbank(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
public class PasswordValidationDTO {
|
||||
private String password;
|
||||
private String loginId;
|
||||
|
||||
+95
-24
@@ -2,19 +2,21 @@ package com.eactive.apim.portal.common.exception;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
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.file.exception.InvalidFileException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
@@ -36,6 +38,7 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(getClass());
|
||||
private final PortalProperties portalProperties;
|
||||
private final Environment environment;
|
||||
|
||||
@ExceptionHandler(value = NotFoundException.class)
|
||||
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
|
||||
@@ -49,12 +52,74 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = UserNotLoginException.class)
|
||||
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
|
||||
return new ModelAndView("redirect:/login");
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = AccessDeniedException.class)
|
||||
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
||||
return new ModelAndView("redirect:/login");
|
||||
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
|
||||
savePostLoginRedirect(request);
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
}
|
||||
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "페이지 접근 권한이 없습니다.");
|
||||
modelAndView.addObject("errorDescription", "해당 페이지를 이용할 수 있는 권한이 없는 계정입니다.\n권한이 필요한 경우 관리자에게 문의해 주세요.");
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미인증 상태로 보호 페이지(HTML 화면)에 접근한 GET 요청의 원 경로를 세션에 저장한다.
|
||||
* 로그인+2FA 완료 후 {@link LoginFinalizer} 가 이 값으로 복귀시킨다(1회용).
|
||||
* open redirect / 무의미 복귀를 막기 위해 내부 화면 GET 경로만 저장한다.
|
||||
*/
|
||||
private void savePostLoginRedirect(HttpServletRequest request) {
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod())) {
|
||||
return;
|
||||
}
|
||||
// 페이지 네비게이션만 대상(AJAX/데이터 요청 제외)
|
||||
String accept = request.getHeader("Accept");
|
||||
if (accept == null || !accept.contains("text/html")) {
|
||||
return;
|
||||
}
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
String ctx = request.getContextPath();
|
||||
String path = (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
|
||||
if (path.isEmpty()) {
|
||||
path = "/";
|
||||
}
|
||||
if (!isSafePostLoginPath(path)) {
|
||||
return;
|
||||
}
|
||||
String qs = request.getQueryString();
|
||||
String target = (qs != null && !qs.isEmpty()) ? path + "?" + qs : path;
|
||||
request.getSession().setAttribute(LoginFinalizer.SESSION_POST_LOGIN_REDIRECT, target);
|
||||
}
|
||||
|
||||
/** 복귀 대상으로 허용할 내부 경로인지(로그인/인증/에러/정적/액션/홈 제외) */
|
||||
private boolean isSafePostLoginPath(String path) {
|
||||
if (path == null || !path.startsWith("/") || path.startsWith("//")) {
|
||||
return false;
|
||||
}
|
||||
return !(path.equals("/")
|
||||
|| path.startsWith("/login")
|
||||
|| path.startsWith("/auth/")
|
||||
|| path.startsWith("/actionLogin")
|
||||
|| path.startsWith("/actionLogout")
|
||||
|| path.startsWith("/error")
|
||||
|| path.startsWith("/css/")
|
||||
|| path.startsWith("/js/")
|
||||
|| path.startsWith("/img/")
|
||||
|| path.startsWith("/webfonts/")
|
||||
|| path.startsWith("/plugins/")
|
||||
|| path.startsWith("/api/")
|
||||
|| path.startsWith("/_proxy")
|
||||
|| path.startsWith("/favicon"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = PortalRedirectException.class)
|
||||
@@ -64,10 +129,10 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = {IllegalArgumentException.class})
|
||||
public ModelAndView handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException ex) {
|
||||
log.error(ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.addObject("errorMessage", ex.getMessage());
|
||||
modelAndView.setViewName("error");
|
||||
log.error("잘못된 요청 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
|
||||
modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -81,34 +146,40 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = HttpMediaTypeNotSupportedException.class)
|
||||
public ModelAndView handleHttpMediaTypeNotSupportedException(HttpServletRequest request, HttpMediaTypeNotSupportedException ex) {
|
||||
log.error(ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.addObject("errorMessage", ex.getMessage());
|
||||
modelAndView.setViewName("error");
|
||||
log.error("지원하지 않는 요청 형식 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
|
||||
modelAndView.addObject("errorDescription", "지원하지 않는 요청 형식입니다.\n잠시 후 다시 시도해 주세요.");
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리되지 않은 예외. 예외 원문은 로그에만 남기고 화면에는 사용자 안내 문구를 표시한다.
|
||||
* 원문(클래스명/메시지)은 운영(prod) 이외 환경의 상세 영역에만 노출한다.
|
||||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
||||
Map<String, Object> params = new HashMap<>(2);
|
||||
params.put("errorMessage", ex.getLocalizedMessage());
|
||||
params.put("stackTrace", ExceptionUtils.getStackTrace(ex)); // Apache Commons Lang
|
||||
params.put("requestURL", request.getRequestURL().toString());
|
||||
|
||||
String mapAsString = request.getParameterMap().entrySet()
|
||||
String requestParams = request.getParameterMap().entrySet()
|
||||
.stream()
|
||||
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
params.put("requestParams", mapAsString);
|
||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||
|
||||
log.error("Exception occurred: ", ex);
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.addObject("errorMessage", ex.getMessage());
|
||||
modelAndView.setViewName("error");
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "서비스 처리 중 오류가 발생했습니다.");
|
||||
modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
|
||||
if (!isProd()) {
|
||||
modelAndView.addObject("errorMessage", ex.getClass().getName() + ": " + ex.getMessage());
|
||||
modelAndView.addObject("activeProfile", String.join(", ", environment.getActiveProfiles()));
|
||||
}
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
private boolean isProd() {
|
||||
return environment.acceptsProfiles(Profiles.of("prod"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = InvalidFileException.class)
|
||||
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
|
||||
+6
-2
@@ -53,10 +53,14 @@ public class PortalRestExceptionHandler {
|
||||
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리되지 않은 예외. 원문 메시지는 로그에만 남기고, 화면에는 사용자 안내 문구를 내려준다.
|
||||
* (JTA 롤백/DB 락 같은 인프라 예외의 영문 원문이 팝업에 그대로 노출되지 않도록)
|
||||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public ResponseEntity<ResponseDTO> handleUnknownxception(HttpServletRequest request, Exception ex) {
|
||||
ex.printStackTrace();
|
||||
ResponseDTO response = new ResponseDTO(500, "500", ex.getMessage());
|
||||
log.error("처리되지 않은 예외 - uri={}", request.getRequestURI(), ex);
|
||||
ResponseDTO response = new ResponseDTO(500, "500", UserErrorMessageResolver.resolveAsHtml(ex));
|
||||
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
import org.springframework.dao.CannotAcquireLockException;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.dao.PessimisticLockingFailureException;
|
||||
import org.springframework.dao.QueryTimeoutException;
|
||||
import org.springframework.transaction.CannotCreateTransactionException;
|
||||
import org.springframework.transaction.TransactionException;
|
||||
|
||||
/**
|
||||
* 예외를 사용자에게 보여줄 안내 문구로 변환한다.
|
||||
*
|
||||
* <p>JTA 롤백/DB 락/타임아웃 같은 인프라 예외는 원문(예: {@code "JTA transaction unexpectedly rolled back
|
||||
* (maybe due to a timeout); nested exception is javax.transaction.RollbackException"})이 그대로
|
||||
* 팝업·에러 화면에 노출되면 사용자가 이해할 수 없고 내부 구조까지 드러난다. 이 클래스가 정해진 한글 문구로 치환한다.
|
||||
*
|
||||
* <p>서비스 코드가 의도적으로 던진 한글 안내문(예: {@code IllegalStateException("이미 초대가 진행 중입니다.")})은
|
||||
* 그대로 유지한다. 판단은 {@link #looksUserFacing(String)} 의 휴리스틱(한글 포함 + 기술 토큰 없음)을 따른다.
|
||||
*
|
||||
* <p>반환 문구는 평문이며 줄바꿈은 {@code \n} 이다. HTML 팝업으로 내려줄 때는 {@link #toHtml(String)} 을 쓴다.
|
||||
*/
|
||||
public final class UserErrorMessageResolver {
|
||||
|
||||
/** 원인을 특정할 수 없을 때의 기본 문구. */
|
||||
public static final String DEFAULT_MESSAGE =
|
||||
"요청을 처리하는 중 오류가 발생했습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
private static final String ROLLBACK_MESSAGE =
|
||||
"요청 처리가 정상적으로 끝나지 않아 변경 내용이 저장되지 않았습니다.\n잠시 후 다시 시도해 주세요. 같은 문제가 반복되면 관리자에게 문의해 주세요.";
|
||||
|
||||
private static final String TIMEOUT_MESSAGE =
|
||||
"처리 시간이 초과되어 요청이 취소되었습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
private static final String CONFLICT_MESSAGE =
|
||||
"이미 등록된 정보이거나 다른 정보와 충돌하여 저장할 수 없습니다.\n입력 내용을 확인해 주세요.";
|
||||
|
||||
private static final String LOCK_MESSAGE =
|
||||
"다른 사용자가 동일한 정보를 변경하고 있습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
private static final String STALE_MESSAGE =
|
||||
"다른 사용자가 먼저 정보를 변경했습니다.\n화면을 새로 고친 뒤 다시 시도해 주세요.";
|
||||
|
||||
private static final String CONNECTION_MESSAGE =
|
||||
"시스템 연결이 원활하지 않아 요청을 처리하지 못했습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
/** 원문 노출을 막아야 하는 기술 토큰. 메시지에 하나라도 있으면 사용자 안내문으로 보지 않는다. */
|
||||
private static final String[] TECHNICAL_TOKENS = {
|
||||
"exception", "Exception", "rollback", "Rollback", "transaction", "Transaction",
|
||||
"SQL", "ORA-", "JTA", "JDBC", "Hibernate", "hibernate", "com.eactive", "org.springframework",
|
||||
"javax.", "java.", "oracle.", "at com.", "Caused by", "null pointer", "NullPointer",
|
||||
"constraint", "Constraint", "statement", "Statement", "SocketTimeout", "Connection"
|
||||
};
|
||||
|
||||
private UserErrorMessageResolver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 예외에서 사용자 안내 문구를 만든다.
|
||||
*
|
||||
* @param ex 발생한 예외 (null 허용)
|
||||
* @return 사용자에게 보여줄 평문 문구. 줄바꿈은 {@code \n}
|
||||
*/
|
||||
public static String resolve(Throwable ex) {
|
||||
if (ex == null) {
|
||||
return DEFAULT_MESSAGE;
|
||||
}
|
||||
|
||||
String infraMessage = resolveInfrastructureMessage(ex);
|
||||
if (infraMessage != null) {
|
||||
return infraMessage;
|
||||
}
|
||||
|
||||
// 서비스가 의도적으로 던진 한글 안내문은 그대로 전달
|
||||
String original = ex.getMessage();
|
||||
if (looksUserFacing(original)) {
|
||||
return original;
|
||||
}
|
||||
|
||||
return DEFAULT_MESSAGE;
|
||||
}
|
||||
|
||||
/** {@link #resolve(Throwable)} 결과를 팝업(HTML)용으로 변환한다. */
|
||||
public static String resolveAsHtml(Throwable ex) {
|
||||
return toHtml(resolve(ex));
|
||||
}
|
||||
|
||||
/** 평문 줄바꿈을 {@code <br>} 로 바꾼다. */
|
||||
public static String toHtml(String plainMessage) {
|
||||
if (plainMessage == null) {
|
||||
return null;
|
||||
}
|
||||
return plainMessage.replace("\n", "<br>");
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랜잭션/DB/연결 계열 인프라 예외인지 원인 체인을 따라가며 판별한다.
|
||||
*
|
||||
* @return 해당 문구, 인프라 예외가 아니면 null
|
||||
*/
|
||||
private static String resolveInfrastructureMessage(Throwable ex) {
|
||||
String chainText = causeChainText(ex);
|
||||
|
||||
// 1. 데이터 충돌(유니크/FK/NOT NULL) — 롤백 판정보다 먼저: 롤백 예외가 이를 감싸고 있어도 원인이 더 구체적이다.
|
||||
if (hasType(ex, DataIntegrityViolationException.class)
|
||||
|| containsAny(chainText, "org.hibernate.exception.ConstraintViolationException",
|
||||
"SQLIntegrityConstraintViolationException",
|
||||
"ORA-00001", "ORA-01400", "ORA-02291", "ORA-02292", "ORA-12899")) {
|
||||
return CONFLICT_MESSAGE;
|
||||
}
|
||||
|
||||
// 2. 낙관적 락 충돌
|
||||
if (hasType(ex, OptimisticLockingFailureException.class)
|
||||
|| containsAny(chainText, "OptimisticLockException", "StaleObjectStateException", "StaleStateException")) {
|
||||
return STALE_MESSAGE;
|
||||
}
|
||||
|
||||
// 3. 비관적 락 / 데드락
|
||||
if (hasType(ex, PessimisticLockingFailureException.class)
|
||||
|| hasType(ex, CannotAcquireLockException.class)
|
||||
|| containsAny(chainText, "ORA-00060", "ORA-02049", "ORA-00054", "deadlock")) {
|
||||
return LOCK_MESSAGE;
|
||||
}
|
||||
|
||||
// 4. 타임아웃 (쿼리/소켓/사용자 취소)
|
||||
if (hasType(ex, QueryTimeoutException.class)
|
||||
|| containsAny(chainText, "SocketTimeoutException", "QueryTimeoutException", "ORA-01013")) {
|
||||
return TIMEOUT_MESSAGE;
|
||||
}
|
||||
|
||||
// 5. 연결 실패
|
||||
if (hasType(ex, DataAccessResourceFailureException.class)
|
||||
|| hasType(ex, CannotCreateTransactionException.class)
|
||||
|| containsAny(chainText, "SQLRecoverableException", "ConnectException", "UnknownHostException",
|
||||
"ORA-03113", "ORA-03114", "ORA-12541", "ORA-12170")) {
|
||||
return CONNECTION_MESSAGE;
|
||||
}
|
||||
|
||||
// 6. JTA 롤백 계열 — 원인을 특정하지 못한 트랜잭션 실패
|
||||
if (hasType(ex, TransactionException.class)
|
||||
|| containsAny(chainText, "RollbackException", "HeuristicMixedException", "HeuristicRollbackException",
|
||||
"rolled back", "rollback only")) {
|
||||
// "Transaction set to rollback only" 은 내부 예외가 삼켜진 경우다. Spring 원문에 "maybe due to a
|
||||
// timeout" 이 붙어 있어도 실제 타임아웃이 아니므로 타임아웃 문구를 쓰지 않는다.
|
||||
if (!containsAny(chainText, "rollback only")
|
||||
&& containsAny(chainText, "timeout", "timed out")) {
|
||||
return TIMEOUT_MESSAGE;
|
||||
}
|
||||
return ROLLBACK_MESSAGE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 그대로 사용자에게 보여줘도 되는 안내문인지 판단한다.
|
||||
* 한글이 포함되고 기술 토큰이 없어야 한다.
|
||||
*/
|
||||
private static boolean looksUserFacing(String message) {
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = message.trim();
|
||||
if (trimmed.isEmpty() || trimmed.length() > 200) {
|
||||
return false;
|
||||
}
|
||||
if (!containsHangul(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
for (String token : TECHNICAL_TOKENS) {
|
||||
if (trimmed.contains(token)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean containsHangul(String text) {
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (c >= 0xAC00 && c <= 0xD7A3) { // 한글 음절
|
||||
return true;
|
||||
}
|
||||
if (c >= 0x1100 && c <= 0x11FF) { // 한글 자모
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 원인 체인의 클래스명 + 메시지를 한 문자열로 모은다(순환 참조 방지). */
|
||||
private static String causeChainText(Throwable ex) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Throwable current = ex;
|
||||
int depth = 0;
|
||||
while (current != null && depth < 10) {
|
||||
sb.append(current.getClass().getName());
|
||||
if (current.getMessage() != null) {
|
||||
sb.append(' ').append(current.getMessage());
|
||||
}
|
||||
sb.append('\n');
|
||||
Throwable cause = current.getCause();
|
||||
if (cause == current) {
|
||||
break;
|
||||
}
|
||||
current = cause;
|
||||
depth++;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean hasType(Throwable ex, Class<? extends Throwable> type) {
|
||||
Throwable current = ex;
|
||||
int depth = 0;
|
||||
while (current != null && depth < 10) {
|
||||
if (type.isInstance(current)) {
|
||||
return true;
|
||||
}
|
||||
Throwable cause = current.getCause();
|
||||
if (cause == current) {
|
||||
return false;
|
||||
}
|
||||
current = cause;
|
||||
depth++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean containsAny(String text, String... keywords) {
|
||||
for (String keyword : keywords) {
|
||||
if (text.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -177,7 +178,8 @@ public class LegacyEncryptionMigrationController {
|
||||
|| "::1".equals(remote);
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.eactive.apim.portal.common.security;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 로그인 실패 계정 잠금 임계 횟수를 DB(PortalProperty)에서 조회한다.
|
||||
*
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code login.failure.lock.count}) 값으로 제어한다.
|
||||
* 값이 없으면 기본값 {@value #DEFAULT_LOCK_COUNT}로 자동 생성되고, 숫자가 아니거나
|
||||
* 0 이하이면 기본값으로 동작한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoginLockPolicy {
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
private static final String NAME = "login.failure.lock.count";
|
||||
|
||||
/** 기본 잠금 임계 횟수 (프로퍼티 미존재/파싱 실패 시) */
|
||||
public static final int DEFAULT_LOCK_COUNT = 5;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 연속 로그인 실패가 이 값 이상이면 계정을 잠근다. */
|
||||
public int lockCount() {
|
||||
String raw = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, NAME, String.valueOf(DEFAULT_LOCK_COUNT),
|
||||
"로그인 연속 실패 계정 잠금 임계 횟수 (이 값 이상 실패 시 잠금)");
|
||||
try {
|
||||
int parsed = Integer.parseInt(raw.trim());
|
||||
if (parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
log.warn("login.failure.lock.count 값이 0 이하({}) - 기본값 {} 사용", parsed, DEFAULT_LOCK_COUNT);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("login.failure.lock.count 값이 숫자가 아님('{}') - 기본값 {} 사용", raw, DEFAULT_LOCK_COUNT);
|
||||
}
|
||||
return DEFAULT_LOCK_COUNT;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,136 @@ public class StringMaskingUtil {
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 ID 마스킹 처리 (로그 출력용).
|
||||
* 이메일 형식이면 {@link #maskEmail(String)} 규칙을, 그 외(스태프 등 비이메일 ID)는 부분 마스킹을 적용한다.
|
||||
*/
|
||||
public static String maskLoginId(String loginId) {
|
||||
if (!isValidString(loginId)) {
|
||||
return loginId;
|
||||
}
|
||||
if (loginId.contains("@")) {
|
||||
return maskEmail(loginId);
|
||||
}
|
||||
int len = loginId.length();
|
||||
if (len <= 2) {
|
||||
return stars(len);
|
||||
}
|
||||
if (len <= 4) {
|
||||
return loginId.charAt(0) + stars(len - 1);
|
||||
}
|
||||
// 5자 이상: 앞2 + 마스킹 + 뒤1
|
||||
return loginId.substring(0, 2) + stars(len - 3) + loginId.charAt(len - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 처리 (로그 출력용). 첫 옥텟만 남기고 나머지를 마스킹한다.
|
||||
* <pre>127.0.0.1 → 127.***.***.***</pre>
|
||||
* IPv4 형식이 아니면 원본을 반환한다(멱등).
|
||||
*/
|
||||
public static String maskIpAddress(String ip) {
|
||||
if (!isValidString(ip)) {
|
||||
return ip;
|
||||
}
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return ip;
|
||||
}
|
||||
return parts[0] + ".***.***.***";
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰/세션ID 등 식별자 마스킹 처리 (로그 출력용). 앞 4자 + {@code ***} + 뒤 4자만 노출한다.
|
||||
* 길이가 짧으면(9자 미만) 전체를 마스킹한다.
|
||||
*/
|
||||
public static String maskToken(String token) {
|
||||
if (!isValidString(token)) {
|
||||
return token;
|
||||
}
|
||||
int len = token.length();
|
||||
if (len < 9) {
|
||||
return stars(len);
|
||||
}
|
||||
return token.substring(0, 4) + "***" + token.substring(len - 4);
|
||||
}
|
||||
|
||||
// 로그에 값을 그대로 남기면 안 되는 민감 헤더(소문자 비교)
|
||||
private static final java.util.Set<String> SENSITIVE_HEADERS = new java.util.HashSet<>(Arrays.asList(
|
||||
"cookie", "set-cookie", "authorization", "proxy-authorization",
|
||||
"x-auth-token", "x-csrf-token", "x-xsrf-token", "x-api-key"));
|
||||
|
||||
private static final String REDACTED = "***REDACTED***";
|
||||
|
||||
/**
|
||||
* 요청/응답 헤더 로깅 시 민감 헤더(Cookie/Authorization 등)의 값을 {@code ***REDACTED***} 로 치환한다.
|
||||
* 그 외 헤더는 원본 값을 반환한다.
|
||||
*/
|
||||
public static String maskHeaderValue(String headerName, String headerValue) {
|
||||
if (headerName != null && SENSITIVE_HEADERS.contains(headerName.toLowerCase())) {
|
||||
return REDACTED;
|
||||
}
|
||||
return headerValue;
|
||||
}
|
||||
|
||||
// 세션 속성 이름에 포함되면 값을 리댁트할 키워드(소문자 부분일치)
|
||||
private static final String[] SENSITIVE_ATTRIBUTE_KEYWORDS = {
|
||||
"token", "secret", "password", "credential", "csrf",
|
||||
"security_context", "loginid", "authentication"};
|
||||
|
||||
/**
|
||||
* 세션 속성 로깅 시 민감 속성(SPRING_SECURITY_CONTEXT, token, loginId 등)의 값을 리댁트한다.
|
||||
* 속성 이름에 민감 키워드가 부분일치하면 {@code ***REDACTED***} 를 반환한다.
|
||||
*/
|
||||
public static String maskAttributeValue(String attrName, String value) {
|
||||
if (attrName != null) {
|
||||
String lower = attrName.toLowerCase();
|
||||
for (String kw : SENSITIVE_ATTRIBUTE_KEYWORDS) {
|
||||
if (lower.contains(kw)) {
|
||||
return REDACTED;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
|
||||
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
|
||||
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||
"refresh_token", "access_token", "id_token", "code", "assertion"));
|
||||
|
||||
/**
|
||||
* {@code application/x-www-form-urlencoded} 본문 로깅 시 민감 파라미터(client_secret/password 등)의
|
||||
* 값을 {@link #maskToken(String)} 규칙(앞4·뒤4)으로 마스킹한다. client_id·grant_type·scope 등은
|
||||
* "client not found" 류 원인 파악을 위해 원본 그대로 남긴다. 폼 형식이 아니면 원본을 반환한다(멱등).
|
||||
* <pre>grant_type=client_credentials&client_id=ABC&client_secret=s3cr3tValue → …&client_secret=s3cr***alue</pre>
|
||||
*/
|
||||
public static String maskFormBody(String body) {
|
||||
if (!isValidString(body) || body.indexOf('=') < 0) {
|
||||
return body;
|
||||
}
|
||||
String[] pairs = body.split("&");
|
||||
StringBuilder sb = new StringBuilder(body.length());
|
||||
for (int i = 0; i < pairs.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
String pair = pairs[i];
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq < 0) {
|
||||
sb.append(pair);
|
||||
continue;
|
||||
}
|
||||
String key = pair.substring(0, eq);
|
||||
String value = pair.substring(eq + 1);
|
||||
if (SENSITIVE_FORM_PARAMS.contains(key.toLowerCase())) {
|
||||
sb.append(key).append('=').append(maskToken(value));
|
||||
} else {
|
||||
sb.append(pair);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// 기존 메서드 오버로딩 (하위 호환성)
|
||||
public static String maskName(String name) {
|
||||
return maskName(name, null, null);
|
||||
|
||||
@@ -5,14 +5,20 @@ import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleValidator.class)
|
||||
@Target({ElementType.FIELD})
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRule {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 3자리 이상 연속,반복 문자 불가)";
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~50자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForDjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForDjbankValidator implements ConstraintValidator<PasswordRuleForDjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForDjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForKbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKbankValidator implements ConstraintValidator<PasswordRuleForKbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKjbankValidator implements ConstraintValidator<PasswordRuleForKjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
@@ -8,11 +11,15 @@ import java.util.regex.Pattern;
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, String> {
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
// 최소 8자, 최대 50자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
private static final int MAX = 50;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
@@ -21,11 +28,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRule constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) {
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
@@ -43,7 +69,15 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
if (strLen > MAX || strLen < MIN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (containsLoginIdLocalPart(password, loginId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (containsMobileSegment(password, mobileNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -84,6 +118,33 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
|
||||
public static boolean containsLoginIdLocalPart(String password, String loginId) {
|
||||
if (password == null || loginId == null || loginId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String username = loginId.split("@")[0].toUpperCase();
|
||||
return !username.isEmpty() && password.toUpperCase().contains(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대전화 번호의 하이픈 세그먼트(010/1234/5678)가 비밀번호에 포함되는지.
|
||||
* DB 에 하이픈 없이 저장된 legacy 값도 잡도록 정규형으로 변환 후 분리한다.
|
||||
*/
|
||||
public static boolean containsMobileSegment(String password, String mobileNumber) {
|
||||
if (password == null || mobileNumber == null || mobileNumber.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String tmpPw = password.toUpperCase();
|
||||
String[] mobileParts = PhoneNumberUtil.normalize(mobileNumber).split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
@@ -94,5 +155,4 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
|
||||
persistenceUnit = "gateway";
|
||||
}
|
||||
|
||||
// 개발 환경용
|
||||
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
|
||||
if (env.matchesProfiles("local")) {
|
||||
properties.put("hibernate.hbm2ddl.auto", "validate");
|
||||
properties.put("hibernate.hbm2ddl.auto", "none");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제(ENFORCE) 가드.
|
||||
*
|
||||
* <p>로그인 시 {@code PortalAuthenticationSuccessHandler} 가 대상자(비밀번호 미변경/만료)에게
|
||||
* 세션 플래그 {@link #ENFORCE_SESSION_ATTR} 를 설정한다. 이 플래그가 있는 동안에는 비밀번호
|
||||
* 변경/검증/로그아웃 경로를 제외한 모든 요청을 변경 페이지로 리다이렉트하여 접근을 차단한다.
|
||||
* 비밀번호 변경 완료 시 플래그가 제거되어 정상 접근이 회복된다.</p>
|
||||
*
|
||||
* <p>정적 자원 경로는 {@code PortalConfigWebDispatcherServlet.addInterceptors} 의
|
||||
* excludePathPatterns 로 제외한다.</p>
|
||||
*/
|
||||
public class PasswordChangeEnforcementInterceptor implements HandlerInterceptor {
|
||||
|
||||
/** ENFORCE 대상 세션 플래그. 로그인 핸들러가 설정, 변경 완료 시 제거. */
|
||||
public static final String ENFORCE_SESSION_ATTR = "pwEnforce";
|
||||
|
||||
/** 강제 상태에서도 접근 허용하는 경로(화이트리스트) */
|
||||
private static final Set<String> ALLOWED_PATHS = new HashSet<>(Arrays.asList(
|
||||
"/password/verify", // 현재 비밀번호 입력(진입) + 검증(POST)
|
||||
"/password/change", // 새 비밀번호 폼(GET) + 실제 변경(POST)
|
||||
"/password/confirm", // 비밀번호 확인 AJAX
|
||||
"/actionLogout.do", // 로그아웃
|
||||
"/login",
|
||||
"/error", "/403", "/404"
|
||||
));
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null || !Boolean.TRUE.equals(session.getAttribute(ENFORCE_SESSION_ATTR))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AJAX/API 등 비(非)페이지 요청은 강제 리다이렉트 대상에서 제외한다.
|
||||
// (세션 heartbeat 같은 인프라 호출을 302로 튕기면 keepalive JS가 세션만료로 오판하여
|
||||
// 로그인↔홈↔변경페이지 무한 리다이렉트가 발생한다.)
|
||||
if (!isTopLevelHtmlNavigation(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (path != null && ALLOWED_PATHS.contains(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 완화 정책: 현재 비밀번호 확인 단계를 제거했으므로 새 비밀번호 폼으로 바로 유도한다.
|
||||
String target = request.getContextPath() + "/password/change";
|
||||
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
|
||||
if (request.getRequestURI().equals(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
response.sendRedirect(target);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 주소창 이동(최상위 HTML 문서 요청)인지 판별한다.
|
||||
* GET + Accept: text/html + 비-AJAX 만 강제 리다이렉트 대상으로 본다.
|
||||
*/
|
||||
private boolean isTopLevelHtmlNavigation(HttpServletRequest request) {
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod())) {
|
||||
return false;
|
||||
}
|
||||
if ("XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With"))) {
|
||||
return false;
|
||||
}
|
||||
String fetchMode = request.getHeader("Sec-Fetch-Mode");
|
||||
if (fetchMode != null && !"navigate".equalsIgnoreCase(fetchMode)) {
|
||||
return false;
|
||||
}
|
||||
String accept = request.getHeader("Accept");
|
||||
return accept != null && accept.contains("text/html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제 정책 레벨.
|
||||
*
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code password.change.enforcement}) 값으로 제어한다.
|
||||
* 키는 DB 관례(점 구분 소문자, 예: {@code session.timeout.minutes})를 따른다.
|
||||
* 값은 enum 명({@code NONE}/{@code PERMISSIVE}/{@code ENFORCE}, 대소문자 무시)이다.</p>
|
||||
* <ul>
|
||||
* <li>{@link #NONE} — 정책 미적용. 안내/강제 없음.</li>
|
||||
* <li>{@link #PERMISSIVE} — 대상자 로그인 시 1회 안내 팝업만. 강제 없음.</li>
|
||||
* <li>{@link #ENFORCE} — 대상자는 비밀번호 변경 완료 전까지 변경/검증/로그아웃 외 접근 차단.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum PasswordEnforcementPolicy {
|
||||
NONE,
|
||||
PERMISSIVE,
|
||||
ENFORCE;
|
||||
|
||||
/** PTL_PROPERTY 그룹명 */
|
||||
public static final String PROPERTY_GROUP = "Portal";
|
||||
/** PTL_PROPERTY 이름 (점 구분 소문자 관례) */
|
||||
public static final String PROPERTY_NAME = "password.change.enforcement";
|
||||
/** 기본값 (배포 직후 동작) */
|
||||
public static final PasswordEnforcementPolicy DEFAULT = ENFORCE;
|
||||
|
||||
/**
|
||||
* 문자열을 정책으로 파싱한다. 대소문자 무시, 미해당/공백이면 {@link #DEFAULT} 반환.
|
||||
*/
|
||||
public static PasswordEnforcementPolicy from(String value) {
|
||||
if (value == null) {
|
||||
return DEFAULT;
|
||||
}
|
||||
try {
|
||||
return PasswordEnforcementPolicy.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-10
@@ -1,9 +1,12 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginConstants;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.security.LoginLockPolicy;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
@@ -13,6 +16,9 @@ import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import org.apache.groovy.util.Maps;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
@@ -42,14 +48,17 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final LoginLockPolicy loginLockPolicy;
|
||||
|
||||
|
||||
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
||||
PortalUserLogService userLogService,
|
||||
MessageHandlerService messageHandlerService) {
|
||||
MessageHandlerService messageHandlerService,
|
||||
LoginLockPolicy loginLockPolicy) {
|
||||
this.portalUserRepository = portalUserRepository;
|
||||
this.userLogService = userLogService;
|
||||
this.messageHandlerService = messageHandlerService;
|
||||
this.loginLockPolicy = loginLockPolicy;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -68,26 +77,27 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername)
|
||||
.orElseThrow(() -> new UserNotFoundException(normalizedUsername));
|
||||
|
||||
int lockCount = loginLockPolicy.lockCount();
|
||||
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
||||
if (user.getLoginFailureCount() >= 5) {
|
||||
if (user.getLoginFailureCount() >= lockCount) {
|
||||
user.setAccountLockYn("Y");
|
||||
|
||||
// 계정 잠금 알림
|
||||
messageHandlerService.publishEvent(
|
||||
MessageCode.USER_ACCOUNT_LOCKED,
|
||||
MessageRecipient.of(user),
|
||||
Maps.of("reason", "5회 이상 로그인 실패로 인한 계정 잠금")) ;;
|
||||
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
|
||||
}
|
||||
portalUserRepository.save(user);
|
||||
|
||||
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
logger.error("{} login try {}", username, e.getMessage());
|
||||
logger.error("{} login try {}", StringMaskingUtil.maskLoginId(username), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
userLogService.logFailure(username, ip, sessionId);
|
||||
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception));
|
||||
|
||||
// 로그인 실패 시 세션 정보 로깅
|
||||
logLoginFailure(request, username, exception);
|
||||
@@ -99,24 +109,45 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
response.sendRedirect(contextPath + "/login");
|
||||
}
|
||||
|
||||
/** 인증 예외 타입 → 감사 로그 실패 사유 코드 매핑 */
|
||||
private LoginFailureReason resolveFailureReason(AuthenticationException exception) {
|
||||
if (exception instanceof UsernameNotFoundException) {
|
||||
return LoginFailureReason.ID_NOT_FOUND;
|
||||
}
|
||||
if (exception instanceof BadCredentialsException) {
|
||||
return LoginFailureReason.PASSWORD_MISMATCH;
|
||||
}
|
||||
if (exception instanceof LockedException) {
|
||||
return LoginFailureReason.ACCOUNT_LOCKED;
|
||||
}
|
||||
if (exception instanceof DisabledException) {
|
||||
return LoginFailureReason.ACCOUNT_DISABLED;
|
||||
}
|
||||
if (exception instanceof SessionAuthenticationException) {
|
||||
return LoginFailureReason.SESSION_AUTH;
|
||||
}
|
||||
logger.warn("미분류 로그인 실패 예외 타입: {}", exception.getClass().getName());
|
||||
return LoginFailureReason.UNKNOWN;
|
||||
}
|
||||
|
||||
private void logLoginFailure(HttpServletRequest request, String username, AuthenticationException exception) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN FAILURE\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(username).append("\n");
|
||||
logMessage.append("Session ID: ").append(request.getSession().getId()).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(request.getSession().getId())).append("\n");
|
||||
logMessage.append("Failed At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Failure Reason: ").append(exception.getLocalizedMessage()).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
@@ -133,7 +164,7 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.common.security.LoginLockPolicy;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
@@ -28,6 +29,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final LoginLockPolicy loginLockPolicy;
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = {AuthenticationException.class})
|
||||
@@ -40,7 +42,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
||||
|
||||
|
||||
if (!user.isAccountNonLocked()) {
|
||||
if (user.getLoginFailureCount() >= 5) {
|
||||
if (user.getLoginFailureCount() >= loginLockPolicy.lockCount()) {
|
||||
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
|
||||
}
|
||||
|
||||
@@ -79,6 +81,21 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
||||
}
|
||||
|
||||
if (user.getPortalOrg() != null) {
|
||||
// 법인은 "정상(ACTIVE)" 상태에서만 로그인 허용 (준비/탈퇴/휴면 차단, 2FA 이전 단계)
|
||||
PortalOrgEnums.OrgStatus orgStatus = user.getPortalOrg().getOrgStatus();
|
||||
if (!PortalOrgEnums.OrgStatus.ACTIVE.equals(orgStatus)) {
|
||||
log.debug("법인 비정상 상태 로그인 차단 - user : {} / org.orgStatus : {}", user.getUsername(), orgStatus);
|
||||
if (PortalOrgEnums.OrgStatus.REMOVED.equals(orgStatus)) {
|
||||
throw new DisabledException("삭제된 법인의 계정입니다. 로그인할 수 없습니다.");
|
||||
}
|
||||
if (PortalOrgEnums.OrgStatus.INACTIVE.equals(orgStatus)) {
|
||||
throw new DisabledException("휴면 상태의 법인입니다. 관리자에게 문의하세요.");
|
||||
}
|
||||
// READY 등 승인 전 상태
|
||||
throw new DisabledException("로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)");
|
||||
}
|
||||
|
||||
// 방어적: ACTIVE 인데 승인 미완료인 예외 케이스 차단
|
||||
if (!user.getPortalOrg().getApprovalStatus().equals(PortalOrgEnums.ApprovalStatus.COMPLETED)) {
|
||||
log.debug("기업사용자 - getApprovalStatus : {} / - org.approvalStatus : {}", user.getApprovalStatus(), user.getPortalOrg().getApprovalStatus());
|
||||
throw new DisabledException("로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)");
|
||||
|
||||
+57
-178
@@ -1,28 +1,18 @@
|
||||
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;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -31,192 +21,81 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/**
|
||||
* 로그인 1차 인증(ID/PW) 성공 핸들러.
|
||||
*
|
||||
* <p>로그인 2FA 가 활성화되어 있고 DORMANT 가 아니면, 후처리를 확정하지 않고
|
||||
* 2FA 대기(pending) 상태로 전환한다: 세션에 대기 정보를 저장하고 SecurityContext 를
|
||||
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
|
||||
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
|
||||
*
|
||||
* <p>2FA off 면 동시 접속(다른 곳 활성 세션) 여부를 확인해, 있으면 확정을 보류하고
|
||||
* {@code /login?duplicate=1} 확인 팝업으로 유도한다({@link DuplicateLoginService}).
|
||||
* 없으면(또는 DORMANT) {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
|
||||
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class PortalAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
|
||||
|
||||
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 PortalUserRepository portalUserRepository;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
private final DuplicateLoginService duplicateLoginService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
String username = request.getParameter("id");
|
||||
// 이메일 소문자 변환 적용
|
||||
String normalizedUsername = username != null ? username.toLowerCase() : null;
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername).orElse(null);
|
||||
if (user == null) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?error=true");
|
||||
return;
|
||||
}
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(username, ip, sessionId);
|
||||
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
|
||||
// 대상 역할(two-factor.login.target-roles, 기본 법인관리자만)에 한해 적용한다.
|
||||
if (twoFactorProperties.isLoginEnabled() && !dormant
|
||||
&& twoFactorProperties.isLoginTargetRole(user.getRoleCode())) {
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
// 세션에 상태 저장
|
||||
if (isEmailVerificationRequired(user)) {
|
||||
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
|
||||
session.setAttribute("emailVerificationRequired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
|
||||
} else if (isDormantAccount(user)) {
|
||||
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
|
||||
session.setAttribute("dormantAccount", true);
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
session.setAttribute("success", "임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
session.setAttribute("success", "비밀번호를 변경한 지 90일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
HttpSession session = request.getSession();
|
||||
twoFactorService.beginLoginChallenge(session, user);
|
||||
|
||||
// 2FA 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
|
||||
SecurityContextHolder.clearContext();
|
||||
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
|
||||
|
||||
response.sendRedirect(request.getContextPath() + "/login?twofactor=1");
|
||||
return;
|
||||
}
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
// 2FA off: 동시 접속(다른 곳 활성 세션)이 있으면 확정을 보류하고 확인 팝업으로 유도한다.
|
||||
// 중복 확인은 비밀번호 검증 통과 후에만 노출한다(사전 체크는 접속 여부/IP 정보 노출).
|
||||
if (!dormant && duplicateLoginService.hasActiveSession(normalizedUsername)) {
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
|
||||
// 세션에 초대 정보 저장 (메인 페이지에서 팝업으로 표시)
|
||||
session.setAttribute("pendingInvitation", true);
|
||||
session.setAttribute("pendingInvitationToken", invitation.getToken());
|
||||
// orgId로 기관명 조회
|
||||
String orgName = portalOrgRepository.findById(invitation.getOrgId())
|
||||
.map(PortalOrg::getOrgName)
|
||||
.orElse("알 수 없는 기관");
|
||||
session.setAttribute("pendingInvitationOrgName", orgName);
|
||||
}
|
||||
}
|
||||
HttpSession session = request.getSession();
|
||||
duplicateLoginService.begin(session, user);
|
||||
|
||||
// 확인 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
|
||||
SecurityContextHolder.clearContext();
|
||||
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
|
||||
|
||||
response.sendRedirect(request.getContextPath() + "/login?duplicate=1");
|
||||
return;
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
||||
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
String decisionToken = (String) request.getSession().getAttribute("decisionToken");
|
||||
if (decisionToken != null) {
|
||||
response.sendRedirect(contextPath + "/signup/decision_process");
|
||||
} else {
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
|
||||
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
|
||||
response.sendRedirect(redirect);
|
||||
}
|
||||
|
||||
private void logLoginSuccess(HttpServletRequest request, HttpSession session, String username) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN SUCCESS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(username).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
logMessage.append("Query String: ").append(request.getQueryString()).append("\n");
|
||||
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST HEADERS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
|
||||
java.util.Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
}
|
||||
}
|
||||
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
|
||||
// 비밀번호 변경 이력이 있는 경우
|
||||
if (latestHistory.isPresent()) {
|
||||
LocalDateTime lastChangeDate = latestHistory.get().getChangeDate();
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(lastChangeDate);
|
||||
}
|
||||
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(user.getCreatedDate());
|
||||
}
|
||||
|
||||
private boolean isTemporaryPasswordLogin(PortalUser user) {
|
||||
Optional<MessageRequest> latestResetRequest = messageRequestRepository.findFirstByEmailAndMessageCodeOrderByRequestDateDesc(
|
||||
user.getLoginId(), MessageCode.USER_PASSWORD_RESET);
|
||||
|
||||
if (latestResetRequest.isPresent()) {
|
||||
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
return !latestHistory.isPresent() || latestHistory.get().getChangeDate().isBefore(latestResetRequest.get().getRequestDate());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDormantAccount(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private boolean isEmailVerificationRequired(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
@@ -32,7 +33,7 @@ public class PortalConfigLog {
|
||||
String query = request.getQueryString();
|
||||
String ip = HttpRequestUtil.getClientIpAddress(request);
|
||||
|
||||
String user = SecurityUtil.getCurrentLoginId();
|
||||
String user = StringMaskingUtil.maskEmail(SecurityUtil.getCurrentLoginId());
|
||||
|
||||
String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user);
|
||||
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
|
||||
|
||||
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
|
||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
|
||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||
|
||||
@@ -107,8 +108,8 @@ public class PortalConfigSecurity {
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(csrfTokenRepository)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -22,9 +23,11 @@ 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.InterceptorRegistry;
|
||||
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.mvc.method.annotation.RequestMappingHandlerAdapter;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter;
|
||||
import org.springframework.web.servlet.resource.VersionResourceResolver;
|
||||
@@ -39,6 +42,9 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
public static final String ERROR = "error";
|
||||
|
||||
private final Environment environment;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
|
||||
private final com.eactive.apim.portal.djb.menu.MenuService menuService;
|
||||
|
||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||
@@ -51,8 +57,14 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
@Value("${app.resource-caching.enabled:false}")
|
||||
private boolean resourceCachingEnabled;
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment) {
|
||||
public PortalConfigWebDispatcherServlet(Environment environment,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
|
||||
com.eactive.apim.portal.djb.menu.MenuService menuService) {
|
||||
this.environment = environment;
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
this.menuService = menuService;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +73,33 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
return new LayoutDialect();
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect 응답에 전역 {@code @ModelAttribute}(브레드크럼용 pageName·showTestAuthNotice·
|
||||
* testAuthNumber·sessionTimeoutMinutes 등)가 쿼리스트링으로 노출되는 것을 차단한다.
|
||||
*
|
||||
* <p>Spring Boot 2.6+ 에서 {@code spring.mvc.ignore-default-model-on-redirect} 프로퍼티가
|
||||
* 제거됐고, 본 애플리케이션은 {@code @EnableWebMvc} 로 Boot 자동설정을 우회하므로
|
||||
* {@link RequestMappingHandlerAdapter} 의 프레임워크 기본값(false)이 적용된다. 그 결과
|
||||
* {@code redirect:} 를 반환하는 모든 컨트롤러에서 {@link com.eactive.apim.portal.common.breadcrumb.GlobalControllerAdvice}
|
||||
* 의 기본 모델 값이 redirect URL 에 append 되어 프론트 전용 파라미터가 주소창에 그대로 드러난다.
|
||||
* 어댑터 생성 이후 플래그만 true 로 뒤집어(Boot/기존 설정은 그대로 유지) 누수를 막는다.</p>
|
||||
*
|
||||
* <p>{@code static} 메서드로 선언해 이 설정 클래스가 조기 초기화되는 것을 피한다.
|
||||
* 명시적 {@code RedirectAttributes}/flash 속성은 영향받지 않는다(기본 모델만 무시).</p>
|
||||
*/
|
||||
@Bean
|
||||
public static BeanPostProcessor ignoreDefaultModelOnRedirectPostProcessor() {
|
||||
return new BeanPostProcessor() {
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) {
|
||||
if (bean instanceof RequestMappingHandlerAdapter) {
|
||||
((RequestMappingHandlerAdapter) bean).setIgnoreDefaultModelOnRedirect(true);
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// RequestMappingHandlerMapping 설정 View Controller 추가
|
||||
// -------------------------------------------------------------
|
||||
@@ -76,6 +115,35 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
registry.addConverter(enabledStatusConverter());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
String[] staticExcludes = {
|
||||
"/css/**", "/js/**", "/img/**", "/images/**", "/webfonts/**",
|
||||
"/font/**", "/html/**", "/plugins/**", "/favicon.ico",
|
||||
"/api/**"};
|
||||
|
||||
// 비밀번호 변경 강제(ENFORCE) 가드. 정적 자원은 제외한다.
|
||||
registry.addInterceptor(new PasswordChangeEnforcementInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes);
|
||||
|
||||
// step-up 2FA 가드. 비밀번호 강제 가드 "다음" 순서로 등록(강제 변경 상태가 우선).
|
||||
// 2FA 엔드포인트 자체(/auth/2fa/**)는 제외해 순환을 막는다.
|
||||
registry.addInterceptor(new com.eactive.apim.portal.apps.auth.twofactor.StepUpAuthInterceptor(
|
||||
twoFactorService, twoFactorProperties))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes)
|
||||
.excludePathPatterns("/auth/2fa/**");
|
||||
|
||||
// 메뉴 접근 권한(ACCESS_ROLES) 가드. 메뉴 경로 정확 일치 시에만 검사하며
|
||||
// 하위 경로는 기존 @Secured / PageRoute.role 안전망에 위임한다.
|
||||
registry.addInterceptor(new com.eactive.apim.portal.djb.menu.MenuAccessInterceptor(menuService))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes)
|
||||
.excludePathPatterns("/internal/**", "/auth/2fa/**",
|
||||
"/login", "/actionLogin.do", "/actionLogout.do", "/error");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public EnabledStatusConverter enabledStatusConverter() {
|
||||
return new EnabledStatusConverter();
|
||||
|
||||
@@ -2,6 +2,7 @@ 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.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -56,11 +57,11 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGOUT\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Logout At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
|
||||
if (authentication != null) {
|
||||
logMessage.append("Username: ").append(authentication.getName()).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(authentication.getName())).append("\n");
|
||||
logMessage.append("Authenticated: ").append(authentication.isAuthenticated()).append("\n");
|
||||
}
|
||||
|
||||
@@ -68,10 +69,10 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
@@ -88,7 +89,7 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
while (attributeNames.hasMoreElements()) {
|
||||
String attrName = attributeNames.nextElement();
|
||||
Object attrValue = session.getAttribute(attrName);
|
||||
String valueStr = attrValue != null ? attrValue.toString() : "null";
|
||||
String valueStr = StringMaskingUtil.maskAttributeValue(attrName, attrValue != null ? attrValue.toString() : "null");
|
||||
if (valueStr.length() > 100) {
|
||||
valueStr = valueStr.substring(0, 97) + "...";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.pagerouter.property.PageRoute;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@@ -25,10 +23,6 @@ public class PortalProperties {
|
||||
|
||||
private String authVirtualCode = "";
|
||||
|
||||
private boolean testAuthNoticeEnabled = false;
|
||||
|
||||
private Map<RoleCode, List<String>> portalSecurity;
|
||||
|
||||
private FileProperties file = new FileProperties();
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
@@ -48,7 +49,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("NEW HTTP SESSION CREATED\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Created At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Max Inactive Interval: ").append(session.getMaxInactiveInterval()).append(" seconds\n");
|
||||
|
||||
@@ -57,10 +58,10 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Remote Port: ").append(request.getRemotePort()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
@@ -84,7 +85,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -106,7 +107,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("HTTP SESSION DESTROYED\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Destroyed At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Max Inactive Interval: ").append(session.getMaxInactiveInterval()).append(" seconds\n");
|
||||
|
||||
@@ -121,7 +122,8 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
while (attributeNames.hasMoreElements()) {
|
||||
String attrName = attributeNames.nextElement();
|
||||
Object attrValue = session.getAttribute(attrName);
|
||||
logMessage.append(String.format(" %-30s : %s\n", attrName, attrValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", attrName,
|
||||
StringMaskingUtil.maskAttributeValue(attrName, attrValue != null ? attrValue.toString() : "null")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logMessage.append(" Unable to retrieve session attributes\n");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.eactive.apim.portal.custom.config;
|
||||
|
||||
//import com.eactive.ext.djb.safedb.DjbSafedbWrapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@@ -17,8 +16,5 @@ public class DjbPasswordEncoder implements PasswordEncoder {
|
||||
@Override
|
||||
public boolean matches(CharSequence rawPassword, String encodedPassword) {
|
||||
return bcryptEncoder.matches(rawPassword, encodedPassword);
|
||||
// DjbSafedbWrapper safedb = DjbSafedbWrapper.getInstance();
|
||||
// String bcryptHash = safedb.decryptNotRnno(encodedPassword);
|
||||
// return bcryptEncoder.matches(rawPassword, bcryptHash);
|
||||
}
|
||||
}
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.controller;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ActiveIncidentDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiCurrentStatusDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.ApiOptionDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.DailyStatDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.IssueDateEntryDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.MaintenanceCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.MyApiStatusDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.dto.PastIssueCardDTO;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiCurrentStatusService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusIssueHistoryService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusQueryService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusSupport;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusUptimeService;
|
||||
import com.eactive.apim.portal.djb.apistatus.service.MyApiStatusQueryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 개발자포탈 API Status 화면.
|
||||
*
|
||||
* <p>장애/점검 데이터는 관리자 공지사항(PTL_NOTICE + DJB_APISTATUS_INCIDENT)과
|
||||
* 자동 탐지(eapim-admin ApiStatusDetectionService)가 채운다. 본 컨트롤러는 읽기 전용이다.</p>
|
||||
*
|
||||
* <p>비로그인 사용자도 모두 조회 가능하며, "내 API 현황"만 로그인을 요구한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/apistatus")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiStatusController {
|
||||
|
||||
private static final int DEFAULT_RECENT_ISSUE_SIZE = 5;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
/** 현재 상태 일괄 조회 시 한 번에 물어볼 수 있는 API 수 */
|
||||
private static final int MAX_STATUS_BATCH = 100;
|
||||
|
||||
private final ApiStatusQueryService apiStatusQueryService;
|
||||
private final ApiStatusUptimeService uptimeService;
|
||||
private final ApiStatusIssueHistoryService issueHistoryService;
|
||||
private final MyApiStatusQueryService myApiStatusQueryService;
|
||||
private final ApiStatusCatalogService catalogService;
|
||||
private final ApiCurrentStatusService apiCurrentStatusService;
|
||||
|
||||
/** P1 - API Status 메인 */
|
||||
@GetMapping
|
||||
public ModelAndView index() {
|
||||
ModelAndView mav = new ModelAndView("djb/apistatus/index");
|
||||
mav.addObject("windowDays", catalogService.getWindowDays());
|
||||
mav.addObject("authenticated", SecurityUtil.isAuthenticated());
|
||||
LocalDateTime lastFireAt = catalogService.getLastMonitorFireAt();
|
||||
mav.addObject("lastFireAt", lastFireAt);
|
||||
mav.addObject("lastFireRelative",
|
||||
ApiStatusSupport.relativeTime(lastFireAt, ApiStatusSupport.now()));
|
||||
mav.addObject("lastStatusChangedAt", catalogService.getLastStatusChangedAt());
|
||||
return mav;
|
||||
}
|
||||
|
||||
/**
|
||||
* P8 - 전체 이슈 이력. 날짜를 지정하지 않으면 조회 기간(90일) 전체를 본다.
|
||||
* 날짜 선택 가능 범위는 서버 기준 일자로 내려 클라이언트 timezone 차이를 없앤다.
|
||||
*/
|
||||
@GetMapping("/issues")
|
||||
public ModelAndView issues(
|
||||
@RequestParam(value = "date", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam(value = "apiId", required = false) String apiId,
|
||||
@RequestParam(value = "kind", required = false) String kind) {
|
||||
|
||||
LocalDate today = ApiStatusSupport.now().toLocalDate();
|
||||
int windowDays = catalogService.getWindowDays();
|
||||
|
||||
ModelAndView mav = new ModelAndView("djb/apistatus/issues");
|
||||
mav.addObject("windowDays", windowDays);
|
||||
mav.addObject("today", today);
|
||||
mav.addObject("minDate", today.minusDays(windowDays - 1L));
|
||||
mav.addObject("selectedDate", date);
|
||||
mav.addObject("selectedApiId", apiId);
|
||||
mav.addObject("selectedKind", kind);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** 이슈 이력 API 필터 - 현재 사용자가 조회 가능한 API 목록 */
|
||||
@GetMapping("/apis.json")
|
||||
@ResponseBody
|
||||
public List<ApiOptionDTO> selectableApis() {
|
||||
return catalogService.getSelectableApis();
|
||||
}
|
||||
|
||||
/** P2 - 90일 가동률 */
|
||||
@GetMapping("/uptime.json")
|
||||
@ResponseBody
|
||||
public List<DailyStatDTO> uptime(
|
||||
@RequestParam(value = "days", defaultValue = "0") int days) {
|
||||
return uptimeService.getDailyStats(days > 0 ? days : catalogService.getWindowDays());
|
||||
}
|
||||
|
||||
/** P3 - 진행 중 장애 */
|
||||
@GetMapping("/active.json")
|
||||
@ResponseBody
|
||||
public List<ActiveIncidentDTO> active() {
|
||||
return apiStatusQueryService.getActiveIncidents();
|
||||
}
|
||||
|
||||
/** P4 - 내 API 현황 (로그인 필수) */
|
||||
@GetMapping("/my-apis.json")
|
||||
@ResponseBody
|
||||
public ResponseEntity<List<MyApiStatusDTO>> myApis() {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return new ResponseEntity<>(Collections.emptyList(), HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
return ResponseEntity.ok(myApiStatusQueryService.getMyApiStatuses());
|
||||
}
|
||||
|
||||
/** P5 - 예정/진행 중 점검 */
|
||||
@GetMapping("/maintenance.json")
|
||||
@ResponseBody
|
||||
public List<MaintenanceCardDTO> maintenance() {
|
||||
return apiStatusQueryService.getOngoingMaintenance();
|
||||
}
|
||||
|
||||
/** P6 - 지난 이슈 (종결) */
|
||||
@GetMapping("/recent-issues.json")
|
||||
@ResponseBody
|
||||
public List<PastIssueCardDTO> recentIssues(
|
||||
@RequestParam(value = "size", defaultValue = "" + DEFAULT_RECENT_ISSUE_SIZE) int size) {
|
||||
return apiStatusQueryService.getRecentClosedIssues(size);
|
||||
}
|
||||
|
||||
/** P7 - 이슈 상세 */
|
||||
@GetMapping("/incident/{incidentId}")
|
||||
@ResponseBody
|
||||
public ResponseEntity<PastIssueCardDTO> incidentDetail(@PathVariable Long incidentId) {
|
||||
return apiStatusQueryService.getIssueDetail(incidentId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
/** P9 - 90일 이슈 일자 인덱스 */
|
||||
@GetMapping("/issues/dates.json")
|
||||
@ResponseBody
|
||||
public List<IssueDateEntryDTO> issueDates(
|
||||
@RequestParam(value = "days", defaultValue = "0") int days,
|
||||
@RequestParam(value = "apiId", required = false) String apiId,
|
||||
@RequestParam(value = "kind", required = false) String kind) {
|
||||
return issueHistoryService.getIssueDates(days > 0 ? days : catalogService.getWindowDays(), apiId, kind);
|
||||
}
|
||||
|
||||
/** P10 - 이슈 목록 (날짜/API/유형 필터) */
|
||||
@GetMapping("/issues/list.json")
|
||||
@ResponseBody
|
||||
public Page<PastIssueCardDTO> issueList(
|
||||
@RequestParam(value = "date", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam(value = "apiId", required = false) String apiId,
|
||||
@RequestParam(value = "kind", required = false) String kind,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "20") int size) {
|
||||
|
||||
int safePage = Math.max(page, 0);
|
||||
int safeSize = size <= 0 ? 20 : Math.min(size, MAX_PAGE_SIZE);
|
||||
return issueHistoryService.getIssues(date, apiId, kind, PageRequest.of(safePage, safeSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* P11 - API 한 건의 현재 상태 (정상/점검/지연/장애). API 상세 화면이 사용한다.
|
||||
*
|
||||
* <p>게시된 장애·점검만 반영하므로 비로그인도 조회 가능하다.</p>
|
||||
*/
|
||||
@GetMapping("/current.json")
|
||||
@ResponseBody
|
||||
public ResponseEntity<ApiCurrentStatusDTO> currentStatus(@RequestParam("apiId") String apiId) {
|
||||
return apiCurrentStatusService.getStatus(apiId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.badRequest().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* P11 - 여러 API 의 현재 상태. {@code ?apiId=A&apiId=B} 로 반복 지정한다.
|
||||
*/
|
||||
@GetMapping("/current-list.json")
|
||||
@ResponseBody
|
||||
public ResponseEntity<List<ApiCurrentStatusDTO>> currentStatuses(
|
||||
@RequestParam(value = "apiId", required = false) List<String> apiIds) {
|
||||
|
||||
if (apiIds != null && apiIds.size() > MAX_STATUS_BATCH) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return ResponseEntity.ok(apiCurrentStatusService.getStatuses(apiIds));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 진행 중 장애 카드 (P3)
|
||||
*/
|
||||
@Data
|
||||
public class ActiveIncidentDTO {
|
||||
|
||||
private Long incidentId;
|
||||
private String noticeId;
|
||||
/** 연결된 공지 제목. 게시 중인 공지가 없으면 null */
|
||||
private String noticeSubject;
|
||||
/** 연결된 공지 본문(HTML). 게시 중인 공지가 없으면 null */
|
||||
private String noticeDetail;
|
||||
private String kind;
|
||||
private String state;
|
||||
private String stateLabel;
|
||||
private String title;
|
||||
private String summary;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startedAt;
|
||||
private long elapsedMinutes;
|
||||
private List<AffectedApiDTO> apis = new ArrayList<>();
|
||||
/** 개발자포탈에 게시되지 않아 개별 노출하지 않는 GW 인터페이스 건수 */
|
||||
private int hiddenApiCount;
|
||||
private List<TimelineEntryDTO> recentTimeline = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.eactive.apim.portal.djb.apistatus.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 이슈 영향 API
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AffectedApiDTO {
|
||||
|
||||
private String apiId;
|
||||
private String apiName;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime recoveredAt;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user