Initial commit: WBX Spring Framework Core
Spring Boot 3.5.0 + Java 21 기반 엔터프라이즈 프레임워크 - Auth: JWT, MFA/TOTP, OAuth2 SSO, PasswordPolicy, LoginHistory - RBAC: Role-Permission, DeptScope, Redis 캐시 - Approval: Handler 패턴 결재 엔진 - Notification: SSE 실시간 알림 - File: Local/Azure Blob/AWS S3/GCP Storage - DataSource: Multi-DB 라우팅 (MySQL/PG/Oracle/MSSQL) - Admin: Thymeleaf 11화면 콘솔 - Compat: WBX FastAPI 호환 (에러/페이징) - Flyway: 4종 DBMS 마이그레이션 - Scripts: install.bat/sh, deploy-prod.sh - Docs: 설치가이드(OnPremise/Cloud), 개발자가이드 PDF Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
7
.dockerignore
일반 파일
7
.dockerignore
일반 파일
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle
|
||||||
|
build
|
||||||
|
!build/libs/*.jar
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
src/test
|
||||||
|
server.log
|
||||||
15
.editorconfig
일반 파일
15
.editorconfig
일반 파일
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yml,yaml,json}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
3
.gitattributes
벤더링됨
일반 파일
3
.gitattributes
벤더링됨
일반 파일
@@ -0,0 +1,3 @@
|
|||||||
|
/gradlew text eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.jar binary
|
||||||
75
.github/workflows/ci.yml
벤더링됨
일반 파일
75
.github/workflows/ci.yml
벤더링됨
일반 파일
@@ -0,0 +1,75 @@
|
|||||||
|
name: CI - Build & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports: [6379:6379]
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: test
|
||||||
|
MYSQL_DATABASE: wbx_spring_test
|
||||||
|
ports: [3306:3306]
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping -h localhost"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=3
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'temurin'
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Grant execute permission
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: ./gradlew test
|
||||||
|
env:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/wbx_spring_test?useSSL=false&allowPublicKeyRetrieval=true
|
||||||
|
SPRING_DATASOURCE_USERNAME: root
|
||||||
|
SPRING_DATASOURCE_PASSWORD: test
|
||||||
|
|
||||||
|
- name: Upload Test Report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-report
|
||||||
|
path: build/reports/tests/
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'temurin'
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Build JAR
|
||||||
|
run: chmod +x gradlew && ./gradlew bootJar
|
||||||
|
|
||||||
|
- name: Upload JAR
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-jar
|
||||||
|
path: build/libs/*.jar
|
||||||
50
.gitignore
벤더링됨
일반 파일
50
.gitignore
벤더링됨
일반 파일
@@ -0,0 +1,50 @@
|
|||||||
|
HELP.md
|
||||||
|
.gradle
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
### Runtime ###
|
||||||
|
*.log
|
||||||
|
server.log
|
||||||
|
uploads/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
### Environment ###
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
.claude/
|
||||||
19
Dockerfile
일반 파일
19
Dockerfile
일반 파일
@@ -0,0 +1,19 @@
|
|||||||
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV TZ=Asia/Seoul
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
|
COPY build/libs/wbx-spring-core-*.jar app.jar
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
|
CMD wget -q --spider http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", \
|
||||||
|
"-XX:+UseG1GC", \
|
||||||
|
"-XX:MaxRAMPercentage=75.0", \
|
||||||
|
"-Djava.security.egd=file:/dev/./urandom", \
|
||||||
|
"-jar", "app.jar"]
|
||||||
70
build.gradle
일반 파일
70
build.gradle
일반 파일
@@ -0,0 +1,70 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id 'org.springframework.boot' version '3.5.0'
|
||||||
|
id 'io.spring.dependency-management' version '1.1.7'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'kr.co.accura.wbx.spring'
|
||||||
|
version = '0.0.1-SNAPSHOT'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
compileOnly {
|
||||||
|
extendsFrom annotationProcessor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-cache'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
implementation 'org.flywaydb:flyway-core'
|
||||||
|
implementation 'org.flywaydb:flyway-mysql'
|
||||||
|
|
||||||
|
// JWT
|
||||||
|
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
|
||||||
|
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
|
||||||
|
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
|
||||||
|
|
||||||
|
// DB Drivers (4개 DBMS)
|
||||||
|
runtimeOnly 'com.mysql:mysql-connector-j'
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
runtimeOnly 'com.oracle.database.jdbc:ojdbc11:23.6.0.24.10'
|
||||||
|
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
|
||||||
|
runtimeOnly 'org.flywaydb:flyway-sqlserver'
|
||||||
|
|
||||||
|
// OpenAPI
|
||||||
|
// SpringDoc — Boot 3.5 호환
|
||||||
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
|
||||||
|
|
||||||
|
// Admin Console (Thymeleaf)
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
|
|
||||||
|
compileOnly 'org.projectlombok:lombok'
|
||||||
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation 'org.springframework.security:spring-security-test'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
testRuntimeOnly 'com.h2database:h2'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('test') {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
42
docker-compose-dev.yml
일반 파일
42
docker-compose-dev.yml
일반 파일
@@ -0,0 +1,42 @@
|
|||||||
|
# WBX Spring Framework — 로컬 개발 인프라
|
||||||
|
# 사용법: docker compose -f docker-compose-dev.yml up -d
|
||||||
|
#
|
||||||
|
# MySQL(:3306) + PostgreSQL(:5432) + Redis(:6379) 동시 실행
|
||||||
|
# --profile mysql → MySQL만
|
||||||
|
# --profile pg → PostgreSQL만
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
profiles: ["mysql", "default"]
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: wbx_spring
|
||||||
|
MYSQL_USER: wbxapp
|
||||||
|
MYSQL_PASSWORD: password
|
||||||
|
MYSQL_ROOT_PASSWORD: rootpassword
|
||||||
|
volumes:
|
||||||
|
- mysql_dev:/var/lib/mysql
|
||||||
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
profiles: ["pg"]
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: wbx_spring
|
||||||
|
POSTGRES_USER: wbxapp
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
volumes:
|
||||||
|
- pg_dev:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_dev:
|
||||||
|
pg_dev:
|
||||||
56
docker-compose.yml
일반 파일
56
docker-compose.yml
일반 파일
@@ -0,0 +1,56 @@
|
|||||||
|
# WBX Spring Framework — 프로덕션 Docker Compose
|
||||||
|
# 사용법: docker compose up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: prod,mysql
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/wbx_spring?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true
|
||||||
|
SPRING_DATASOURCE_USERNAME: wbxapp
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||||
|
SPRING_DATA_REDIS_HOST: redis
|
||||||
|
SERVER_CONTEXT_PATH: ${SERVER_CONTEXT_PATH:-/}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: wbx_spring
|
||||||
|
MYSQL_USER: wbxapp
|
||||||
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
redis_data:
|
||||||
670
docs/00-setup-guide.md
일반 파일
670
docs/00-setup-guide.md
일반 파일
@@ -0,0 +1,670 @@
|
|||||||
|
# WBX Spring Framework — 설치/배포 가이드
|
||||||
|
|
||||||
|
> 소스: `D:\WBX02\wbx-spring-core`
|
||||||
|
> 기술: Spring Boot 3.5.0 + Java 21 + MySQL/PG/Oracle/MSSQL + Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 사전 요구사항
|
||||||
|
|
||||||
|
### 1-1. 서버 사양
|
||||||
|
|
||||||
|
| 항목 | 최소 사양 | 권장 사양 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| OS | RHEL 8+ / Ubuntu 22.04+ / Windows Server 2019+ | RHEL 9 / Rocky Linux 9 |
|
||||||
|
| CPU | 4 vCPU | 8 vCPU |
|
||||||
|
| RAM | 8 GB | 16 GB |
|
||||||
|
| Disk | 50 GB SSD | 100 GB SSD |
|
||||||
|
| Network | 1 Gbps | 10 Gbps |
|
||||||
|
|
||||||
|
### 1-2. 필수 소프트웨어
|
||||||
|
|
||||||
|
| SW | 버전 | 용도 | 필수 |
|
||||||
|
|-----|------|------|:---:|
|
||||||
|
| JDK | 21 LTS (Eclipse Temurin 권장) | Spring Boot 런타임 | ✅ |
|
||||||
|
| Git | 2.x | 소스코드 관리 | ✅ |
|
||||||
|
| MySQL | 8.0+ | DB (택 1) | ✅ |
|
||||||
|
| PostgreSQL | 14+ | DB (택 1) | 선택 |
|
||||||
|
| Oracle | 19c+ | DB (택 1) | 선택 |
|
||||||
|
| MSSQL | 2019+ | DB (택 1) | 선택 |
|
||||||
|
| Redis | 7.x | 캐시/세션 | 권장 |
|
||||||
|
| Docker | 24+ | 컨테이너 배포 | 선택 |
|
||||||
|
| Nginx/Caddy | latest | 리버스 프록시 (프로덕션) | 선택 |
|
||||||
|
|
||||||
|
> Gradle은 Wrapper(`gradlew`)가 자동 다운로드하므로 별도 설치 불필요
|
||||||
|
|
||||||
|
### 1-3. 포트
|
||||||
|
|
||||||
|
| 포트 | 서비스 | 접근 범위 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| 443 | HTTPS (Nginx/Caddy) | 외부 |
|
||||||
|
| 80 | HTTP → 443 리다이렉트 | 외부 |
|
||||||
|
| 8080 | Spring Boot | 내부 (localhost) |
|
||||||
|
| 3306 | MySQL | 내부 |
|
||||||
|
| 5432 | PostgreSQL | 내부 |
|
||||||
|
| 1521 | Oracle | 내부 |
|
||||||
|
| 1433 | MSSQL | 내부 |
|
||||||
|
| 6379 | Redis | 내부 (bind 127.0.0.1) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. JDK 21 설치
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 방법 A: Winget (권장)
|
||||||
|
winget install EclipseAdoptium.Temurin.21.JDK
|
||||||
|
|
||||||
|
# 방법 B: ZIP 수동 설치
|
||||||
|
# https://adoptium.net/temurin/releases/?version=21 에서 다운로드
|
||||||
|
# 환경변수 설정
|
||||||
|
[System.Environment]::SetEnvironmentVariable('JAVA_HOME', 'D:\tools\jdk-21.0.6+7', 'User')
|
||||||
|
[System.Environment]::SetEnvironmentVariable('Path', $env:Path + ';D:\tools\jdk-21.0.6+7\bin', 'User')
|
||||||
|
java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux (RHEL/Rocky/CentOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y java-21-openjdk-devel
|
||||||
|
# 또는 Temurin
|
||||||
|
sudo dnf install -y https://packages.adoptium.net/artifactory/rpm/centos/9/$(uname -m)/Packages/temurin-21-jdk-*.rpm
|
||||||
|
echo 'export JAVA_HOME=/usr/lib/jvm/temurin-21-jdk' >> ~/.bashrc
|
||||||
|
source ~/.bashrc && java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ubuntu/Debian
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y wget apt-transport-https gpg
|
||||||
|
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | sudo gpg --dearmor -o /usr/share/keyrings/adoptium.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
|
||||||
|
sudo apt update && sudo apt install -y temurin-21-jdk
|
||||||
|
java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install temurin@21
|
||||||
|
java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 소스코드 & 로컬 개발
|
||||||
|
|
||||||
|
### 3-1. 소스코드 가져오기
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/accura0117/wbx-spring-core.git
|
||||||
|
cd wbx-spring-core
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-2. 빠른 설치 (설치 스크립트)
|
||||||
|
|
||||||
|
스크립트가 JDK/Git/Docker 사전 검사, 빌드, .env 템플릿 생성, 디렉토리 생성을 자동 처리합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
scripts\install.bat
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
chmod +x scripts/install.sh && ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-3. 로컬 DB 생성 (Docker Compose 또는 직접)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 방법 A: Docker Compose (권장)
|
||||||
|
docker compose -f docker-compose-dev.yml up -d
|
||||||
|
# → MySQL(:3306) + Redis(:6379) 자동 시작
|
||||||
|
|
||||||
|
# PostgreSQL 사용 시:
|
||||||
|
docker compose -f docker-compose-dev.yml --profile pg up -d
|
||||||
|
# → PostgreSQL(:5432) + Redis(:6379) 자동 시작
|
||||||
|
|
||||||
|
# 방법 B: 직접 설치 후 DB 생성
|
||||||
|
# MySQL
|
||||||
|
CREATE DATABASE wbx_spring CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'wbxapp'@'localhost' IDENTIFIED BY 'password';
|
||||||
|
GRANT ALL ON wbx_spring.* TO 'wbxapp'@'localhost';
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
CREATE USER wbxapp WITH PASSWORD 'password';
|
||||||
|
CREATE DATABASE wbx_spring OWNER wbxapp ENCODING 'UTF8';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-4. 빌드 & 실행
|
||||||
|
|
||||||
|
설치 스크립트를 사용하지 않는 경우 수동으로 빌드합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
gradlew.bat build -x test
|
||||||
|
gradlew.bat bootRun
|
||||||
|
|
||||||
|
# Linux/macOS
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew build -x test
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-5. 확인
|
||||||
|
|
||||||
|
> 기본 context-path는 `/spring`입니다. 독립 도메인 시 `SERVER_CONTEXT_PATH=/`로 변경.
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8080/spring/health → {"status":"ok","app":"wbx-spring"}
|
||||||
|
http://localhost:8080/spring/admin/login → Admin Console
|
||||||
|
http://localhost:8080/spring/swagger-ui → Swagger UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3-6. 초기 관리자 등록
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/spring/api/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"admin@company.com","username":"admin","password":"Admin1234!","fullName":"관리자","isAdmin":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 프로필 시스템
|
||||||
|
|
||||||
|
### 4-1. 9개 프로필
|
||||||
|
|
||||||
|
| 프로필 | 파일 | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| (기본) | application.yml | 로컬 개발 (MySQL, ddl-auto:update) |
|
||||||
|
| **prod** | application-prod.yml | 프로덕션 (Flyway 활성, Swagger 비활성, 로깅) |
|
||||||
|
| **mysql** | application-mysql.yml | MySQL 드라이버/Dialect/Flyway |
|
||||||
|
| **postgresql** | application-postgresql.yml | PostgreSQL 드라이버/Dialect/Flyway |
|
||||||
|
| **oracle** | application-oracle.yml | Oracle 드라이버/Dialect/Flyway |
|
||||||
|
| **mssql** | application-mssql.yml | MSSQL 드라이버/Dialect/Flyway |
|
||||||
|
| **azure** | application-azure.yml | Azure Entra SSO + Blob Storage |
|
||||||
|
| **aws** | application-aws.yml | AWS Cognito SSO + S3 |
|
||||||
|
| **test** | application-test.yml | 테스트 (H2 인메모리, Flyway 비활성) |
|
||||||
|
|
||||||
|
### 4-2. 프로필 조합 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On-Premise MySQL
|
||||||
|
java -jar app.jar --spring.profiles.active=prod,mysql
|
||||||
|
|
||||||
|
# On-Premise Oracle
|
||||||
|
java -jar app.jar --spring.profiles.active=prod,oracle
|
||||||
|
|
||||||
|
# Azure + MSSQL
|
||||||
|
java -jar app.jar --spring.profiles.active=prod,mssql,azure
|
||||||
|
|
||||||
|
# AWS + PostgreSQL
|
||||||
|
java -jar app.jar --spring.profiles.active=prod,postgresql,aws
|
||||||
|
|
||||||
|
# 테스트
|
||||||
|
./gradlew test # application-test.yml 자동 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-3. 환경변수
|
||||||
|
|
||||||
|
| 변수 | 기본값 | 설명 | 필수 |
|
||||||
|
|------|--------|------|:---:|
|
||||||
|
| `JWT_SECRET` | (dev key) | JWT 시크릿 (프로덕션 필수 변경!) | ✅ |
|
||||||
|
| `SERVER_CONTEXT_PATH` | `/spring` | 독립 도메인이면 `/` | |
|
||||||
|
| `DB_HOST` | localhost | DB 호스트 | |
|
||||||
|
| `DB_PORT` | 3306 | DB 포트 | |
|
||||||
|
| `DB_NAME` | wbx_spring | DB 이름 | |
|
||||||
|
| `DB_USER` | wbxapp | DB 사용자 | |
|
||||||
|
| `DB_PASS` | password | DB 비밀번호 | ✅ |
|
||||||
|
| `DB_POOL_SIZE` | 20 | HikariCP 커넥션 풀 | |
|
||||||
|
| `SPRING_DATA_REDIS_HOST` | localhost | Redis 호스트 | |
|
||||||
|
| `CORS_ORIGIN` | https://app.company.com | CORS 허용 도메인 | |
|
||||||
|
| `SPRING_PROFILES_ACTIVE` | (없음) | 프로필 조합 | ✅ |
|
||||||
|
|
||||||
|
#### Azure 전용
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `AZURE_CLIENT_ID` | Entra App Registration Client ID |
|
||||||
|
| `AZURE_CLIENT_SECRET` | Entra App Secret |
|
||||||
|
| `AZURE_TENANT_ID` | Azure AD Tenant ID |
|
||||||
|
| `AZURE_STORAGE_ACCOUNT` | Blob Storage Account Name |
|
||||||
|
| `AZURE_STORAGE_KEY` | Blob Storage Access Key |
|
||||||
|
| `AZURE_CONTAINER` | Blob Container Name (기본: uploads) |
|
||||||
|
|
||||||
|
#### AWS 전용
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `AWS_COGNITO_CLIENT_ID` | Cognito User Pool Client ID |
|
||||||
|
| `AWS_COGNITO_CLIENT_SECRET` | Cognito Client Secret |
|
||||||
|
| `AWS_USER_POOL_ID` | Cognito User Pool ID |
|
||||||
|
| `AWS_REGION` | AWS Region (기본: ap-northeast-2) |
|
||||||
|
| `AWS_S3_BUCKET` | S3 Bucket Name |
|
||||||
|
| `AWS_ACCESS_KEY` | IAM Access Key |
|
||||||
|
| `AWS_SECRET_KEY` | IAM Secret Key |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 주요 기능 활성화
|
||||||
|
|
||||||
|
### 5-1. MFA/TOTP (Google Authenticator 호환)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
wbx:
|
||||||
|
spring:
|
||||||
|
mfa:
|
||||||
|
enabled: true # true로 활성화
|
||||||
|
force-for-external: true # 외부 사용자 강제
|
||||||
|
force-for-internal: false # 내부 사용자 (Entra CA로 대체)
|
||||||
|
totp-issuer: "WBX Platform"
|
||||||
|
```
|
||||||
|
|
||||||
|
활성화 시 로그인 흐름: `POST /api/auth/login` → `mfa_required: true` → `POST /api/auth/mfa/verify`
|
||||||
|
|
||||||
|
### 5-2. Azure Entra SSO
|
||||||
|
|
||||||
|
`--spring.profiles.active=prod,mssql,azure` + 환경변수 설정으로 자동 활성화.
|
||||||
|
OAuth2 OIDC 로그인 → WBX 호환 JWT 자동 발급 → 사용자 자동 등록.
|
||||||
|
|
||||||
|
### 5-3. 파일 스토리지 전환
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
wbx:
|
||||||
|
spring:
|
||||||
|
file:
|
||||||
|
storage-type: local # 기본: 로컬 파일시스템
|
||||||
|
# storage-type: azure-blob # Azure Blob Storage
|
||||||
|
# storage-type: aws-s3 # AWS S3
|
||||||
|
# storage-type: gcp-storage # Google Cloud Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-4. Multi-DataSource 라우팅
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
wbx:
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
routing-enabled: true # 활성화
|
||||||
|
wbxgw:
|
||||||
|
url: jdbc:mysql://localhost:3306/wbx_gw
|
||||||
|
username: wbxapp
|
||||||
|
password: password
|
||||||
|
```
|
||||||
|
|
||||||
|
서비스에서 `@DataSource("wbxgw")` 어노테이션으로 DB 전환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 프로덕션 배포
|
||||||
|
|
||||||
|
### 6-1. 서비스 계정 (Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd -r -m -s /bin/bash wbxapp
|
||||||
|
sudo mkdir -p /opt/wbx-app
|
||||||
|
sudo chown wbxapp:wbxapp /opt/wbx-app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-2. 타임존 · 인코딩
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo timedatectl set-timezone Asia/Seoul
|
||||||
|
sudo localectl set-locale LANG=ko_KR.UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-3. 시스템 리밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/security/limits.d/wbxapp.conf
|
||||||
|
wbxapp soft nofile 65535
|
||||||
|
wbxapp hard nofile 65535
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-4. Redis 보안
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sed -i 's/^bind .*/bind 127.0.0.1/' /etc/redis/redis.conf
|
||||||
|
echo 'requirepass RedisP@ss123' | sudo tee -a /etc/redis/redis.conf
|
||||||
|
sudo systemctl restart redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-5. 배포 디렉토리
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/wbx-app/
|
||||||
|
├── app.jar # Spring Boot Fat JAR
|
||||||
|
├── .env # 환경변수 (시크릿)
|
||||||
|
├── logs/ # 로그
|
||||||
|
├── uploads/ # 파일 업로드 (local storage)
|
||||||
|
└── backup/ # 백업
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-6. JAR 빌드 & 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 빌드 서버
|
||||||
|
./gradlew bootJar
|
||||||
|
|
||||||
|
# 운영 서버로 전송
|
||||||
|
scp build/libs/wbx-spring-core-*.jar wbxapp@server:/opt/wbx-app/app.jar
|
||||||
|
|
||||||
|
# .env 파일
|
||||||
|
cat > /opt/wbx-app/.env << 'EOF'
|
||||||
|
JWT_SECRET=your-production-secret-key-minimum-256-bits
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=wbx_spring
|
||||||
|
DB_USER=wbxapp
|
||||||
|
DB_PASS=StrongP@ss
|
||||||
|
SPRING_PROFILES_ACTIVE=prod,mysql
|
||||||
|
SERVER_CONTEXT_PATH=/
|
||||||
|
EOF
|
||||||
|
chmod 600 /opt/wbx-app/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-7. systemd 서비스 (Linux)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/wbx-app.service
|
||||||
|
[Unit]
|
||||||
|
Description=WBX Spring Application
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=wbxapp
|
||||||
|
WorkingDirectory=/opt/wbx-app
|
||||||
|
EnvironmentFile=/opt/wbx-app/.env
|
||||||
|
ExecStart=/usr/bin/java \
|
||||||
|
-XX:+UseG1GC \
|
||||||
|
-XX:MaxRAMPercentage=75.0 \
|
||||||
|
-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE} \
|
||||||
|
-jar /opt/wbx-app/app.jar
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:/opt/wbx-app/logs/app.log
|
||||||
|
StandardError=append:/opt/wbx-app/logs/app.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now wbx-app
|
||||||
|
journalctl -u wbx-app -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-8. Windows 서비스
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# NSSM으로 서비스 등록 (https://nssm.cc)
|
||||||
|
nssm install WbxSpring "D:\tools\jdk-21\bin\java.exe" "-Xmx512m -jar D:\wbx-app\app.jar --spring.profiles.active=prod,mysql"
|
||||||
|
nssm set WbxSpring AppDirectory D:\wbx-app
|
||||||
|
nssm set WbxSpring AppEnvironmentExtra JWT_SECRET=your-secret SERVER_CONTEXT_PATH=/
|
||||||
|
nssm start WbxSpring
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6-9. Docker 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew bootJar
|
||||||
|
docker build -t wbx-spring:latest .
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 리버스 프록시
|
||||||
|
|
||||||
|
### 7-1. Nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream spring_app {
|
||||||
|
server 127.0.0.1:8080;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name app.company.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/app.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/app.key;
|
||||||
|
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://spring_app;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 50m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE 알림 (버퍼링 비활성화)
|
||||||
|
location /api/notifications/stream {
|
||||||
|
proxy_pass http://spring_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7-2. Caddy
|
||||||
|
|
||||||
|
```
|
||||||
|
app.company.com {
|
||||||
|
reverse_proxy localhost:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# 공유 도메인 (WBX + Spring Boot)
|
||||||
|
wbx.kr {
|
||||||
|
handle /spring/* {
|
||||||
|
reverse_proxy localhost:8080
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy localhost:5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7-3. WBX FastAPI 동시 운영
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Nginx — URL prefix로 분기
|
||||||
|
location /api/gw/ {
|
||||||
|
proxy_pass http://127.0.0.1:8001; # WBX FastAPI
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080; # Spring Boot
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> JWT SECRET_KEY를 두 시스템이 동일하게 설정해야 SSO가 작동합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 방화벽 · 보안
|
||||||
|
|
||||||
|
### Linux (firewalld)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo firewall-cmd --permanent --add-service=http
|
||||||
|
sudo firewall-cmd --permanent --add-service=https
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
# DB 포트는 외부 차단 (기본)
|
||||||
|
```
|
||||||
|
|
||||||
|
### SELinux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo setsebool -P httpd_can_network_connect 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 보안 체크리스트
|
||||||
|
|
||||||
|
- SSH 키 기반 인증 (비밀번호 로그인 비활성화)
|
||||||
|
- 서비스 계정(wbxapp)에 sudo 권한 없음
|
||||||
|
- DB 포트 외부 차단 (localhost만 접근)
|
||||||
|
- Redis bind 127.0.0.1 + requirepass
|
||||||
|
- .env 파일 600 권한
|
||||||
|
- Swagger UI 프로덕션 비활성화 (prod 프로필 자동)
|
||||||
|
- Nginx 보안 헤더 (HSTS, X-Frame, X-Content-Type)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Admin Console
|
||||||
|
|
||||||
|
11개 화면, 25개 엔드포인트:
|
||||||
|
|
||||||
|
| 화면 | URL | 기능 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 로그인 | /admin/login | 이메일+비밀번호 폼 인증 |
|
||||||
|
| 대시보드 | /admin | 활성 사용자, 로그인 횟수, 역할 수 |
|
||||||
|
| 사용자 목록 | /admin/users | 추가, 상세 링크 |
|
||||||
|
| 사용자 상세 | /admin/users/{id} | 수정, 삭제, 잠금해제, 비밀번호초기화, 역할할당/해제 |
|
||||||
|
| 역할 목록 | /admin/roles | 추가, 삭제, 상세 링크 |
|
||||||
|
| 역할 상세 | /admin/roles/{id} | 수정, 권한 추가/삭제 |
|
||||||
|
| 권한 매트릭스 | /admin/permissions | 전체 역할-모듈-액션 현황, 삭제 |
|
||||||
|
| 로그인 이력 | /admin/login-history | 최근 50건 |
|
||||||
|
| 감사 로그 | /admin/audit-logs | 최근 100건 |
|
||||||
|
| 시스템 설정 | /admin/config | K-V 설정 추가/수정 |
|
||||||
|
| 시스템 상태 | /admin/system-health | JVM Heap, OS, Java, Spring Boot 버전 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 백업 · 복구
|
||||||
|
|
||||||
|
### DB 백업 (cron)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /opt/wbx-app/backup/db-backup.sh
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
mysqldump -u wbxapp -p wbx_spring | gzip > /opt/wbx-app/backup/wbx_spring_${DATE}.sql.gz
|
||||||
|
find /opt/wbx-app/backup -name '*.gz' -mtime +30 -delete
|
||||||
|
|
||||||
|
# crontab -e
|
||||||
|
0 2 * * * /opt/wbx-app/backup/db-backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 앱 롤백
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /opt/wbx-app/app.jar /opt/wbx-app/backup/app-prev.jar
|
||||||
|
# 새 JAR 배포 후 문제 발생 시:
|
||||||
|
cp /opt/wbx-app/backup/app-prev.jar /opt/wbx-app/app.jar
|
||||||
|
sudo systemctl restart wbx-app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 모니터링
|
||||||
|
|
||||||
|
### Actuator + Prometheus
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# application-prod.yml에 이미 설정됨
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics,prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health Check
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
curl http://localhost:8080/actuator/health
|
||||||
|
curl http://localhost:8080/actuator/prometheus # Prometheus 메트릭
|
||||||
|
|
||||||
|
# Grafana 대시보드: ID 12900 (JVM Micrometer) Import
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Flyway 마이그레이션
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/resources/db/migration/
|
||||||
|
├── common/ # 공통 Seed (INSERT)
|
||||||
|
│ ├── V001__seed_roles.sql
|
||||||
|
│ └── V002__seed_system_config.sql
|
||||||
|
├── mysql/ # MySQL DDL (11 테이블)
|
||||||
|
│ └── V001__create_tables.sql
|
||||||
|
├── postgresql/ # PostgreSQL DDL
|
||||||
|
│ └── V001__create_tables.sql
|
||||||
|
├── oracle/ # Oracle DDL
|
||||||
|
│ └── V001__create_tables.sql
|
||||||
|
└── mssql/ # MSSQL DDL
|
||||||
|
└── V001__create_tables.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
- 개발: `hibernate.ddl-auto=update` (Flyway 비활성)
|
||||||
|
- 프로덕션: `flyway.enabled=true` + `ddl-auto=validate` (자동)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 체크리스트
|
||||||
|
|
||||||
|
### 로컬 개발
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] JDK 21 설치 + java -version 확인
|
||||||
|
[ ] Git clone
|
||||||
|
[ ] 설치 스크립트 실행 (scripts/install.bat 또는 scripts/install.sh)
|
||||||
|
[ ] docker-compose-dev.yml up (또는 직접 DB/Redis 설치)
|
||||||
|
[ ] ./gradlew bootRun → http://localhost:8080/spring/health OK
|
||||||
|
[ ] 관리자 등록 (POST /spring/api/auth/register)
|
||||||
|
[ ] Admin Console 로그인 (/spring/admin/login)
|
||||||
|
[ ] Swagger UI 확인 (/spring/swagger-ui)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프로덕션
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] 배포 스크립트 실행 (sudo scripts/deploy-prod.sh) 또는 수동 설정:
|
||||||
|
[ ] 서비스 계정 (wbxapp) 생성
|
||||||
|
[ ] 타임존 Asia/Seoul, 로케일 UTF-8
|
||||||
|
[ ] .env 파일 생성 (JWT_SECRET 필수 변경!)
|
||||||
|
[ ] DB 생성 + 사용자 권한
|
||||||
|
[ ] Redis 설치 + bind 127.0.0.1 + requirepass
|
||||||
|
[ ] JAR 빌드 (./gradlew bootJar)
|
||||||
|
[ ] 서버 전송 + /opt/wbx-app 배치
|
||||||
|
[ ] systemd 서비스 등록 + 시작
|
||||||
|
[ ] Health Check 확인
|
||||||
|
[ ] 리버스 프록시 설정 (Nginx/Caddy)
|
||||||
|
[ ] SSL 인증서 설치
|
||||||
|
[ ] 방화벽 443 오픈, DB 포트 내부만
|
||||||
|
[ ] SELinux httpd_can_network_connect (RHEL)
|
||||||
|
[ ] 관리자 등록 + Admin Console 접속 확인
|
||||||
|
[ ] DB 백업 cron 등록
|
||||||
|
[ ] 모니터링 Prometheus 연동 (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 트러블슈팅
|
||||||
|
|
||||||
|
| 증상 | 원인 | 해결 |
|
||||||
|
|------|------|------|
|
||||||
|
| BUILD FAILED | JDK 버전 | `java -version` → 21 확인 |
|
||||||
|
| DB 연결 실패 | URL/인증 | .env + application-{db}.yml 확인 |
|
||||||
|
| 포트 충돌 | 8080 사용 중 | `server.port=8081` 또는 기존 프로세스 종료 |
|
||||||
|
| Admin 리다이렉트 오류 | context-path | `SERVER_CONTEXT_PATH` 환경변수 확인 |
|
||||||
|
| JWT 에러 | 시크릿 키 불일치 | WBX FastAPI와 동일 `JWT_SECRET` 사용 |
|
||||||
|
| Redis 연결 실패 | 미실행 | Redis 없이도 동작 (캐시 비활성화) |
|
||||||
|
| 한글 깨짐 | DB 인코딩 | `CHARACTER SET utf8mb4` 확인 |
|
||||||
|
| Flyway 실패 | DDL 호환 | DB 프로필 확인 (oracle/mssql/mysql/postgresql) |
|
||||||
|
| SSO 오류 | OAuth2 설정 | AZURE_CLIENT_ID, AZURE_TENANT_ID 확인 |
|
||||||
|
| MFA 안됨 | 미활성화 | `wbx.spring.mfa.enabled=true` 설정 |
|
||||||
|
| 502 Bad Gateway | 앱 미기동 | `systemctl status wbx-app`, logs 확인 |
|
||||||
|
| OOM (메모리) | 힙 부족 | `-XX:MaxRAMPercentage=75` 또는 RAM 증설 |
|
||||||
203
docs/01-project-structure.md
일반 파일
203
docs/01-project-structure.md
일반 파일
@@ -0,0 +1,203 @@
|
|||||||
|
# WBX Spring Core — 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
wbx-spring-core/
|
||||||
|
├── build.gradle # Gradle 빌드 (Spring Boot 3.5, Java 21)
|
||||||
|
├── settings.gradle
|
||||||
|
├── gradlew / gradlew.bat # Gradle Wrapper (별도 설치 불필요)
|
||||||
|
├── Dockerfile # 컨테이너 빌드 (Temurin 21 JRE Alpine)
|
||||||
|
├── docker-compose.yml # 프로덕션 (App + MySQL + Redis)
|
||||||
|
├── docker-compose-dev.yml # 로컬 개발 인프라 (MySQL/PG + Redis)
|
||||||
|
├── .dockerignore
|
||||||
|
├── .editorconfig # IDE 공통 코드 스타일
|
||||||
|
├── .gitignore
|
||||||
|
├── .github/workflows/ci.yml # GitHub Actions CI
|
||||||
|
│
|
||||||
|
├── docs/ # ★ 문서
|
||||||
|
│ ├── 00-setup-guide.md # 설치/배포 가이드
|
||||||
|
│ └── 01-project-structure.md # 프로젝트 구조 (본 문서)
|
||||||
|
│
|
||||||
|
├── src/main/java/kr/co/accura/wbx/spring/
|
||||||
|
│ ├── WbxSpringCoreApplication.java # 메인 (@SpringBootApplication)
|
||||||
|
│ ├── HealthController.java # GET /health
|
||||||
|
│ │
|
||||||
|
│ ├── auth/ # 인증 (18파일)
|
||||||
|
│ │ ├── WbxUser.java # 사용자 Entity
|
||||||
|
│ │ ├── WbxUserDetails.java # Spring Security UserDetails
|
||||||
|
│ │ ├── WbxUserRepository.java
|
||||||
|
│ │ ├── AuthController.java # login/register/me/refresh/logout/mfa
|
||||||
|
│ │ ├── JwtProvider.java # JWT 발급/검증 (WBX 호환)
|
||||||
|
│ │ ├── JwtFilter.java # Bearer Token 필터
|
||||||
|
│ │ ├── ApiKeyFilter.java # X-API-Key 서버-서버 인증
|
||||||
|
│ │ ├── PasswordPolicy.java # 비밀번호 규칙 검증
|
||||||
|
│ │ ├── RefreshTokenService.java # Refresh Token 관리
|
||||||
|
│ │ ├── WbxRefreshToken.java # Entity
|
||||||
|
│ │ ├── RefreshTokenRepository.java
|
||||||
|
│ │ ├── WbxLoginHistory.java # 로그인 이력 Entity
|
||||||
|
│ │ ├── LoginHistoryRepository.java
|
||||||
|
│ │ ├── MfaService.java # TOTP 생성/검증/백업코드
|
||||||
|
│ │ ├── MfaController.java # MFA setup/verify/disable
|
||||||
|
│ │ ├── WbxTotpSecret.java # TOTP 시크릿 Entity
|
||||||
|
│ │ ├── TotpSecretRepository.java
|
||||||
|
│ │ └── SsoSuccessHandler.java # Azure Entra OAuth2 성공 핸들러
|
||||||
|
│ │
|
||||||
|
│ ├── rbac/ # 권한 (7파일)
|
||||||
|
│ │ ├── DeptScope.java # OWN/DEPT/COMPANY + toSpec()
|
||||||
|
│ │ ├── PermissionEvaluator.java # @wbx.check()
|
||||||
|
│ │ ├── WbxRole.java # 역할 Entity
|
||||||
|
│ │ ├── WbxUserRole.java # 사용자-역할 매핑
|
||||||
|
│ │ ├── WbxUserRoleRepository.java
|
||||||
|
│ │ ├── RolePermission.java # 역할-권한
|
||||||
|
│ │ └── RolePermissionRepository.java
|
||||||
|
│ │
|
||||||
|
│ ├── approval/ # 결재 엔진 (9파일)
|
||||||
|
│ │ ├── ApprovalHandler.java # 핸들러 인터페이스
|
||||||
|
│ │ ├── ApprovalHandlerRegistry.java # 자동 수집 Registry
|
||||||
|
│ │ ├── UnifiedApprovalController.java # 통합 API
|
||||||
|
│ │ ├── ApprovalCompletedEvent.java # Spring Event
|
||||||
|
│ │ ├── ApprovalResult.java # 결과 DTO
|
||||||
|
│ │ ├── ApprovalHistoryDto.java
|
||||||
|
│ │ ├── ApprovalLineDto.java
|
||||||
|
│ │ ├── ApprovalPendingDto.java
|
||||||
|
│ │ └── ActionRequest.java
|
||||||
|
│ │
|
||||||
|
│ ├── notification/ # 알림 (4파일)
|
||||||
|
│ │ ├── SseNotificationService.java # SSE 실시간 알림
|
||||||
|
│ │ ├── NotificationController.java # /notifications/stream
|
||||||
|
│ │ ├── Notification.java # Entity
|
||||||
|
│ │ └── NotificationDto.java
|
||||||
|
│ │
|
||||||
|
│ ├── audit/ # 감사 로그 (3파일)
|
||||||
|
│ │ ├── AuditLogService.java
|
||||||
|
│ │ ├── WbxAuditLog.java
|
||||||
|
│ │ └── AuditLogRepository.java
|
||||||
|
│ │
|
||||||
|
│ ├── file/ # 파일 스토리지 (6파일)
|
||||||
|
│ │ ├── FileStorageService.java # 인터페이스 (upload/download/delete/getPresignedUrl)
|
||||||
|
│ │ ├── LocalFileStorageService.java # 로컬 구현
|
||||||
|
│ │ ├── AzureBlobStorageService.java # Azure Blob (SAS Token)
|
||||||
|
│ │ ├── AwsS3StorageService.java # AWS S3
|
||||||
|
│ │ ├── GcpStorageService.java # GCP Cloud Storage
|
||||||
|
│ │ └── WbxFileUpload.java # Entity
|
||||||
|
│ │
|
||||||
|
│ ├── datasource/ # Multi-DataSource (4파일)
|
||||||
|
│ │ ├── DataSource.java # @DataSource 어노테이션
|
||||||
|
│ │ ├── WbxRoutingDataSource.java # ThreadLocal 기반 라우팅
|
||||||
|
│ │ ├── DataSourceAspect.java # AOP
|
||||||
|
│ │ └── MultiDataSourceConfig.java # Bean 설정
|
||||||
|
│ │
|
||||||
|
│ ├── compat/ # WBX 호환 (2파일)
|
||||||
|
│ │ ├── WbxErrorHandler.java # detail 키 에러
|
||||||
|
│ │ └── WbxPaginationConfig.java # skip/limit → Pageable
|
||||||
|
│ │
|
||||||
|
│ ├── config/ # 설정 (5파일)
|
||||||
|
│ │ ├── WbxSpringProperties.java # wbx.spring.* 설정
|
||||||
|
│ │ ├── SecurityAutoConfig.java # 2 FilterChain (Admin+API+SSO)
|
||||||
|
│ │ ├── CorsAutoConfig.java
|
||||||
|
│ │ ├── OpenApiConfig.java # Swagger
|
||||||
|
│ │ └── WbxSystemConfig.java # K-V 설정 Entity
|
||||||
|
│ │
|
||||||
|
│ ├── common/ # 공통 (4파일)
|
||||||
|
│ │ ├── BaseEntity.java # JPA Auditing
|
||||||
|
│ │ ├── BusinessException.java
|
||||||
|
│ │ ├── NotFoundException.java
|
||||||
|
│ │ └── SecurityUtils.java
|
||||||
|
│ │
|
||||||
|
│ └── admin/ # 관리자 (6파일)
|
||||||
|
│ ├── AdminViewController.java # Thymeleaf 11화면 + 25 엔드포인트
|
||||||
|
│ ├── AdminLoginController.java # /admin/login
|
||||||
|
│ ├── AdminUserDetailsService.java # 폼 로그인 인증
|
||||||
|
│ ├── AdminController.java # Admin REST API
|
||||||
|
│ ├── WbxRoleRepository.java
|
||||||
|
│ └── WbxSystemConfigRepository.java
|
||||||
|
│
|
||||||
|
├── src/main/resources/
|
||||||
|
│ ├── application.yml # 메인 설정 (로컬 개발)
|
||||||
|
│ ├── application-prod.yml # 프로덕션 (Flyway, 로깅)
|
||||||
|
│ ├── application-mysql.yml # MySQL 프로필
|
||||||
|
│ ├── application-postgresql.yml # PostgreSQL 프로필
|
||||||
|
│ ├── application-oracle.yml # Oracle 프로필
|
||||||
|
│ ├── application-mssql.yml # MSSQL 프로필
|
||||||
|
│ ├── application-azure.yml # Azure (SSO + Blob)
|
||||||
|
│ ├── application-aws.yml # AWS (Cognito + S3)
|
||||||
|
│ ├── application-test.yml # 테스트 (H2 인메모리)
|
||||||
|
│ ├── templates/admin/ # Thymeleaf 템플릿 (12화면)
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ ├── dashboard.html
|
||||||
|
│ │ ├── users.html
|
||||||
|
│ │ ├── user-detail.html
|
||||||
|
│ │ ├── roles.html
|
||||||
|
│ │ ├── role-detail.html
|
||||||
|
│ │ ├── permissions.html
|
||||||
|
│ │ ├── login-history.html
|
||||||
|
│ │ ├── audit-logs.html
|
||||||
|
│ │ ├── config.html
|
||||||
|
│ │ ├── system-health.html
|
||||||
|
│ │ └── fragments.html # 사이드바 공통
|
||||||
|
│ ├── static/admin/css/admin.css # Admin UI 스타일
|
||||||
|
│ └── db/migration/ # Flyway SQL (4종 DBMS)
|
||||||
|
│ ├── common/
|
||||||
|
│ │ ├── V001__seed_roles.sql
|
||||||
|
│ │ └── V002__seed_system_config.sql
|
||||||
|
│ ├── mysql/V001__create_tables.sql
|
||||||
|
│ ├── postgresql/V001__create_tables.sql
|
||||||
|
│ ├── oracle/V001__create_tables.sql
|
||||||
|
│ └── mssql/V001__create_tables.sql
|
||||||
|
│
|
||||||
|
└── src/test/java/ # 테스트
|
||||||
|
```
|
||||||
|
|
||||||
|
## 패키지별 요약
|
||||||
|
|
||||||
|
| 패키지 | 파일 | 핵심 |
|
||||||
|
|--------|:----:|------|
|
||||||
|
| auth | 18 | JWT, Login, Register, Refresh, Password, ApiKey, MFA/TOTP, SSO, LoginHistory |
|
||||||
|
| rbac | 7 | DeptScope, PermissionEvaluator(@wbx), Role, UserRole, Permission |
|
||||||
|
| approval | 9 | Handler(interface), Registry, UnifiedController, Event |
|
||||||
|
| notification | 4 | SSE, Controller, Entity |
|
||||||
|
| audit | 3 | AuditLogService, Entity |
|
||||||
|
| file | 6 | StorageService(interface), Local, Azure, AWS, GCP |
|
||||||
|
| datasource | 4 | @DataSource, RoutingDataSource, AOP, MultiConfig |
|
||||||
|
| compat | 2 | ErrorHandler, Pagination |
|
||||||
|
| config | 5 | Properties, Security, CORS, OpenAPI, SystemConfig |
|
||||||
|
| common | 4 | BaseEntity, Exception, SecurityUtils |
|
||||||
|
| admin | 6 | Thymeleaf Admin Console (12화면, 24 엔드포인트) |
|
||||||
|
| **합계** | **70** | |
|
||||||
|
|
||||||
|
## DB 테이블 (11개, Hibernate 자동 생성 / Flyway 마이그레이션)
|
||||||
|
|
||||||
|
| 테이블 | Entity | 용도 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| wbx_users | WbxUser | 사용자 (SSO, MFA 필드 포함) |
|
||||||
|
| wbx_roles | WbxRole | 역할 |
|
||||||
|
| wbx_user_roles | WbxUserRole | 사용자-역할 |
|
||||||
|
| wbx_role_permissions | RolePermission | 역할-권한 (module+action+scope) |
|
||||||
|
| wbx_refresh_tokens | WbxRefreshToken | JWT 갱신 토큰 |
|
||||||
|
| wbx_totp_secrets | WbxTotpSecret | MFA TOTP 시크릿 (AES 암호화) |
|
||||||
|
| wbx_login_history | WbxLoginHistory | 로그인 이력 |
|
||||||
|
| wbx_notifications | Notification | 알림 |
|
||||||
|
| wbx_audit_logs | WbxAuditLog | 감사 로그 |
|
||||||
|
| wbx_file_uploads | WbxFileUpload | 파일 이력 |
|
||||||
|
| wbx_system_config | WbxSystemConfig | 시스템 설정 (K-V) |
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
| Method | Path | 인증 | 설명 |
|
||||||
|
|--------|------|:---:|------|
|
||||||
|
| GET | /health | - | 헬스 체크 |
|
||||||
|
| POST | /api/auth/login | - | 로그인 → JWT (MFA 분기) |
|
||||||
|
| POST | /api/auth/register | - | 회원가입 |
|
||||||
|
| GET | /api/auth/me | JWT | 내 정보 |
|
||||||
|
| PUT | /api/auth/password/change | JWT | 비밀번호 변경 |
|
||||||
|
| POST | /api/auth/refresh | - | 토큰 갱신 |
|
||||||
|
| POST | /api/auth/logout | JWT | 로그아웃 |
|
||||||
|
| POST | /api/auth/mfa/verify | - | MFA 2단계 검증 |
|
||||||
|
| POST | /api/auth/mfa/setup | JWT | MFA 설정 시작 (QR) |
|
||||||
|
| POST | /api/auth/mfa/setup/verify | JWT | MFA 활성화 + 백업코드 |
|
||||||
|
| DELETE | /api/auth/mfa | JWT | MFA 비활성화 |
|
||||||
|
| POST | /api/auth/mfa/backup-verify | - | 백업코드 인증 |
|
||||||
|
| GET | /api/notifications/stream | JWT | SSE 실시간 알림 |
|
||||||
|
| GET | /api/notifications/unread-count | JWT | 미읽음 수 |
|
||||||
|
| POST | /api/approvals/unified/action/{type}/{id}/approve | JWT | 결재 승인 |
|
||||||
|
| POST | /api/approvals/unified/action/{type}/{id}/reject | JWT | 결재 반려 |
|
||||||
|
| GET | /api/approvals/unified/pending | JWT | 결재 대기 |
|
||||||
BIN
docs/WBX_Spring_Framework_개발자가이드.pdf
일반 파일
BIN
docs/WBX_Spring_Framework_개발자가이드.pdf
일반 파일
바이너리 파일은 표시되지 않습니다.
바이너리 파일은 표시되지 않습니다.
바이너리 파일은 표시되지 않습니다.
BIN
gradle/wrapper/gradle-wrapper.jar
벤더링됨
일반 파일
BIN
gradle/wrapper/gradle-wrapper.jar
벤더링됨
일반 파일
바이너리 파일은 표시되지 않습니다.
7
gradle/wrapper/gradle-wrapper.properties
벤더링됨
일반 파일
7
gradle/wrapper/gradle-wrapper.properties
벤더링됨
일반 파일
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
gradlew
벤더링됨
일반 파일
251
gradlew
벤더링됨
일반 파일
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
gradlew.bat
벤더링됨
일반 파일
94
gradlew.bat
벤더링됨
일반 파일
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
215
scripts/deploy-prod.sh
일반 파일
215
scripts/deploy-prod.sh
일반 파일
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================
|
||||||
|
# WBX Spring Core — 프로덕션 배포 스크립트 (Linux)
|
||||||
|
# 사용법: sudo ./scripts/deploy-prod.sh
|
||||||
|
# ============================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------- 색상 ----------
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
|
ok() { echo -e " ${GREEN}[OK]${NC} $1"; }
|
||||||
|
warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
fail() { echo -e " ${RED}[FAIL]${NC} $1"; exit 1; }
|
||||||
|
info() { echo -e " ${CYAN}[INFO]${NC} $1"; }
|
||||||
|
|
||||||
|
APP_DIR="/opt/wbx-app"
|
||||||
|
SERVICE_USER="wbxapp"
|
||||||
|
SERVICE_NAME="wbx-app"
|
||||||
|
JAR_NAME="app.jar"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " WBX Spring Core — 프로덕션 배포"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------- Root 권한 ----------
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
fail "root 권한이 필요합니다: sudo $0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 1. 서비스 계정 ----------
|
||||||
|
echo "1. 서비스 계정 (${SERVICE_USER})"
|
||||||
|
if id "$SERVICE_USER" &>/dev/null; then
|
||||||
|
ok "이미 존재"
|
||||||
|
else
|
||||||
|
useradd -r -m -s /bin/bash "$SERVICE_USER"
|
||||||
|
ok "생성 완료"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 2. 타임존/로케일 ----------
|
||||||
|
echo "2. 시스템 설정"
|
||||||
|
timedatectl set-timezone Asia/Seoul 2>/dev/null && ok "타임존: Asia/Seoul" || warn "타임존 설정 실패 — 수동 확인"
|
||||||
|
|
||||||
|
# ---------- 3. 디렉토리 ----------
|
||||||
|
echo "3. 디렉토리 구조"
|
||||||
|
mkdir -p "${APP_DIR}"/{logs,uploads,backup}
|
||||||
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}"
|
||||||
|
ok "${APP_DIR}/{logs,uploads,backup}"
|
||||||
|
|
||||||
|
# ---------- 4. 시스템 리밋 ----------
|
||||||
|
echo "4. 파일 디스크립터 리밋"
|
||||||
|
LIMITS_FILE="/etc/security/limits.d/${SERVICE_USER}.conf"
|
||||||
|
if [ ! -f "$LIMITS_FILE" ]; then
|
||||||
|
cat > "$LIMITS_FILE" << EOF
|
||||||
|
${SERVICE_USER} soft nofile 65535
|
||||||
|
${SERVICE_USER} hard nofile 65535
|
||||||
|
EOF
|
||||||
|
ok "${LIMITS_FILE} 생성"
|
||||||
|
else
|
||||||
|
ok "이미 존재"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 5. JAR 복사 ----------
|
||||||
|
echo "5. JAR 배포"
|
||||||
|
JAR_SOURCE="build/libs/wbx-spring-core-*.jar"
|
||||||
|
FOUND_JAR=$(ls $JAR_SOURCE 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$FOUND_JAR" ]; then
|
||||||
|
warn "JAR 파일 없음 — 먼저 ./gradlew bootJar 실행 필요"
|
||||||
|
else
|
||||||
|
# 이전 버전 백업
|
||||||
|
if [ -f "${APP_DIR}/${JAR_NAME}" ]; then
|
||||||
|
cp "${APP_DIR}/${JAR_NAME}" "${APP_DIR}/backup/${JAR_NAME}.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
info "이전 JAR 백업 완료"
|
||||||
|
fi
|
||||||
|
cp "$FOUND_JAR" "${APP_DIR}/${JAR_NAME}"
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}/${JAR_NAME}"
|
||||||
|
ok "$(basename "$FOUND_JAR") → ${APP_DIR}/${JAR_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 6. .env 템플릿 ----------
|
||||||
|
echo "6. 환경변수 파일"
|
||||||
|
if [ ! -f "${APP_DIR}/.env" ]; then
|
||||||
|
cat > "${APP_DIR}/.env" << 'ENVEOF'
|
||||||
|
# ===== WBX Spring Core — 프로덕션 환경변수 =====
|
||||||
|
|
||||||
|
SPRING_PROFILES_ACTIVE=prod,mysql
|
||||||
|
SERVER_CONTEXT_PATH=/
|
||||||
|
|
||||||
|
# JWT (필수 변경!)
|
||||||
|
JWT_SECRET=your-production-secret-key-minimum-256-bits-long
|
||||||
|
|
||||||
|
# DB
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=wbx_spring
|
||||||
|
DB_USER=wbxapp
|
||||||
|
DB_PASS=StrongP@ss
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
SPRING_DATA_REDIS_HOST=localhost
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=https://app.company.com
|
||||||
|
|
||||||
|
# 로그
|
||||||
|
LOG_PATH=/opt/wbx-app/logs/app.log
|
||||||
|
ENVEOF
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}/.env"
|
||||||
|
chmod 600 "${APP_DIR}/.env"
|
||||||
|
ok "${APP_DIR}/.env 생성 (값을 반드시 수정하세요!)"
|
||||||
|
else
|
||||||
|
ok "이미 존재 — 건너뜀"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 7. systemd 서비스 ----------
|
||||||
|
echo "7. systemd 서비스"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=WBX Spring Application
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
WorkingDirectory=${APP_DIR}
|
||||||
|
EnvironmentFile=${APP_DIR}/.env
|
||||||
|
ExecStart=/usr/bin/java \\
|
||||||
|
-XX:+UseG1GC \\
|
||||||
|
-XX:MaxRAMPercentage=75.0 \\
|
||||||
|
-Dspring.profiles.active=\${SPRING_PROFILES_ACTIVE} \\
|
||||||
|
-jar ${APP_DIR}/${JAR_NAME}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:${APP_DIR}/logs/app.log
|
||||||
|
StandardError=append:${APP_DIR}/logs/app.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME" --quiet
|
||||||
|
ok "${SERVICE_FILE}"
|
||||||
|
|
||||||
|
# ---------- 8. 백업 cron ----------
|
||||||
|
echo "8. DB 백업 cron"
|
||||||
|
BACKUP_SCRIPT="${APP_DIR}/backup/db-backup.sh"
|
||||||
|
if [ ! -f "$BACKUP_SCRIPT" ]; then
|
||||||
|
cat > "$BACKUP_SCRIPT" << 'CRONEOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# WBX Spring — DB 백업 (crontab: 0 2 * * *)
|
||||||
|
set -euo pipefail
|
||||||
|
source /opt/wbx-app/.env
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="/opt/wbx-app/backup"
|
||||||
|
|
||||||
|
mysqldump -h"${DB_HOST}" -u"${DB_USER}" -p"${DB_PASS}" "${DB_NAME}" \
|
||||||
|
| gzip > "${BACKUP_DIR}/${DB_NAME}_${DATE}.sql.gz"
|
||||||
|
|
||||||
|
# 30일 이상 백업 삭제
|
||||||
|
find "${BACKUP_DIR}" -name '*.gz' -mtime +30 -delete
|
||||||
|
|
||||||
|
echo "[$(date)] backup done: ${DB_NAME}_${DATE}.sql.gz"
|
||||||
|
CRONEOF
|
||||||
|
chmod +x "$BACKUP_SCRIPT"
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "$BACKUP_SCRIPT"
|
||||||
|
ok "${BACKUP_SCRIPT} 생성"
|
||||||
|
|
||||||
|
# crontab 등록
|
||||||
|
CRON_LINE="0 2 * * * ${BACKUP_SCRIPT} >> ${APP_DIR}/logs/backup.log 2>&1"
|
||||||
|
(crontab -u "$SERVICE_USER" -l 2>/dev/null | grep -v "db-backup.sh"; echo "$CRON_LINE") \
|
||||||
|
| crontab -u "$SERVICE_USER" -
|
||||||
|
ok "crontab 등록 (매일 02:00)"
|
||||||
|
else
|
||||||
|
ok "이미 존재 — 건너뜀"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 9. 방화벽 ----------
|
||||||
|
echo "9. 방화벽"
|
||||||
|
if command -v firewall-cmd &>/dev/null; then
|
||||||
|
firewall-cmd --permanent --add-service=http --quiet 2>/dev/null || true
|
||||||
|
firewall-cmd --permanent --add-service=https --quiet 2>/dev/null || true
|
||||||
|
firewall-cmd --reload --quiet 2>/dev/null || true
|
||||||
|
ok "HTTP/HTTPS 허용"
|
||||||
|
else
|
||||||
|
warn "firewalld 없음 — 수동으로 방화벽 설정 필요"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 10. SELinux ----------
|
||||||
|
echo "10. SELinux"
|
||||||
|
if command -v setsebool &>/dev/null && getenforce 2>/dev/null | grep -qi enforcing; then
|
||||||
|
setsebool -P httpd_can_network_connect 1 2>/dev/null
|
||||||
|
ok "httpd_can_network_connect = 1"
|
||||||
|
else
|
||||||
|
info "SELinux 비활성 또는 미설치 — 건너뜀"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 결과 ----------
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo -e " ${GREEN}배포 준비 완료${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 체크리스트:"
|
||||||
|
echo " [ ] ${APP_DIR}/.env 값 수정 (JWT_SECRET, DB_PASS 필수!)"
|
||||||
|
echo " [ ] DB 생성 + 사용자 권한 부여"
|
||||||
|
echo " [ ] Redis 설치 + bind 127.0.0.1 + requirepass"
|
||||||
|
echo " [ ] JAR 빌드: ./gradlew bootJar"
|
||||||
|
echo " [ ] 서비스 시작: sudo systemctl start ${SERVICE_NAME}"
|
||||||
|
echo " [ ] 확인: curl http://localhost:8080/health"
|
||||||
|
echo " [ ] 리버스 프록시 설정 (Nginx/Caddy)"
|
||||||
|
echo " [ ] SSL 인증서 설치"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
152
scripts/install.bat
일반 파일
152
scripts/install.bat
일반 파일
@@ -0,0 +1,152 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: WBX Spring Core — Windows 설치 스크립트
|
||||||
|
:: 사용법: scripts\install.bat
|
||||||
|
:: ============================================================
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==========================================
|
||||||
|
echo WBX Spring Core — 설치 점검
|
||||||
|
echo ==========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set ERRORS=0
|
||||||
|
|
||||||
|
:: ---------- 1. JDK 21 ----------
|
||||||
|
echo 1. JDK 확인
|
||||||
|
where java >nul 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 (
|
||||||
|
for /f "tokens=3" %%v in ('java -version 2^>^&1 ^| findstr /i "version"') do (
|
||||||
|
set "JAVA_FULL=%%~v"
|
||||||
|
)
|
||||||
|
for /f "tokens=1 delims=." %%m in ("!JAVA_FULL!") do set "JAVA_MAJOR=%%m"
|
||||||
|
if !JAVA_MAJOR! GEQ 21 (
|
||||||
|
echo [OK] JDK !JAVA_FULL!
|
||||||
|
) else (
|
||||||
|
echo [FAIL] JDK !JAVA_FULL! — 21 이상 필요
|
||||||
|
set /a ERRORS+=1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [FAIL] java 명령어 없음 — JDK 21 설치 필요
|
||||||
|
set /a ERRORS+=1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: ---------- 2. Git ----------
|
||||||
|
echo 2. Git 확인
|
||||||
|
where git >nul 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 (
|
||||||
|
for /f "delims=" %%g in ('git --version') do echo [OK] %%g
|
||||||
|
) else (
|
||||||
|
echo [FAIL] git 없음
|
||||||
|
set /a ERRORS+=1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: ---------- 3. Docker (선택) ----------
|
||||||
|
echo 3. Docker 확인 (선택)
|
||||||
|
where docker >nul 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 (
|
||||||
|
for /f "delims=" %%d in ('docker --version') do echo [OK] %%d
|
||||||
|
) else (
|
||||||
|
echo [WARN] Docker 미설치 — DB/Redis를 직접 설치해야 합니다
|
||||||
|
)
|
||||||
|
|
||||||
|
:: ---------- 4. 빌드 ----------
|
||||||
|
echo 4. Gradle 빌드
|
||||||
|
if !ERRORS! GTR 0 (
|
||||||
|
echo [FAIL] 사전 요구사항 미충족 — 빌드 건너뜀
|
||||||
|
) else (
|
||||||
|
call gradlew.bat build -x test --console=plain -q
|
||||||
|
if !ERRORLEVEL! equ 0 (
|
||||||
|
echo [OK] BUILD SUCCESSFUL
|
||||||
|
) else (
|
||||||
|
echo [FAIL] 빌드 실패
|
||||||
|
set /a ERRORS+=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: ---------- 5. .env 템플릿 ----------
|
||||||
|
echo 5. 환경변수 파일
|
||||||
|
if not exist .env (
|
||||||
|
(
|
||||||
|
echo # ===== WBX Spring Core — 환경변수 =====
|
||||||
|
echo # 이 파일을 환경에 맞게 수정하세요.
|
||||||
|
echo.
|
||||||
|
echo # --- 프로필 ---
|
||||||
|
echo SPRING_PROFILES_ACTIVE=prod,mysql
|
||||||
|
echo.
|
||||||
|
echo # --- 서버 ---
|
||||||
|
echo SERVER_CONTEXT_PATH=/
|
||||||
|
echo.
|
||||||
|
echo # --- JWT ^(필수 변경!^) ---
|
||||||
|
echo JWT_SECRET=your-production-secret-key-minimum-256-bits-long
|
||||||
|
echo.
|
||||||
|
echo # --- DB ---
|
||||||
|
echo DB_HOST=localhost
|
||||||
|
echo DB_PORT=3306
|
||||||
|
echo DB_NAME=wbx_spring
|
||||||
|
echo DB_USER=wbxapp
|
||||||
|
echo DB_PASS=StrongP@ss
|
||||||
|
echo.
|
||||||
|
echo # --- Redis ---
|
||||||
|
echo SPRING_DATA_REDIS_HOST=localhost
|
||||||
|
echo.
|
||||||
|
echo # --- CORS ---
|
||||||
|
echo CORS_ORIGINS=https://app.company.com
|
||||||
|
echo.
|
||||||
|
echo # --- 로그 경로 ---
|
||||||
|
echo LOG_PATH=D:\wbx-app\logs\app.log
|
||||||
|
echo.
|
||||||
|
echo # --- Azure SSO ^(azure 프로필 사용 시^) ---
|
||||||
|
echo # AZURE_CLIENT_ID=
|
||||||
|
echo # AZURE_CLIENT_SECRET=
|
||||||
|
echo # AZURE_TENANT_ID=
|
||||||
|
echo.
|
||||||
|
echo # --- Azure Blob Storage ---
|
||||||
|
echo # AZURE_STORAGE_ACCOUNT=
|
||||||
|
echo # AZURE_STORAGE_KEY=
|
||||||
|
echo # AZURE_CONTAINER=uploads
|
||||||
|
echo.
|
||||||
|
echo # --- AWS Cognito ^(aws 프로필 사용 시^) ---
|
||||||
|
echo # AWS_COGNITO_CLIENT_ID=
|
||||||
|
echo # AWS_COGNITO_CLIENT_SECRET=
|
||||||
|
echo # AWS_USER_POOL_ID=
|
||||||
|
echo # AWS_REGION=ap-northeast-2
|
||||||
|
echo.
|
||||||
|
echo # --- AWS S3 ---
|
||||||
|
echo # AWS_S3_BUCKET=
|
||||||
|
echo # AWS_ACCESS_KEY=
|
||||||
|
echo # AWS_SECRET_KEY=
|
||||||
|
) > .env
|
||||||
|
echo [OK] .env 생성 완료 (값을 수정하세요)
|
||||||
|
) else (
|
||||||
|
echo [WARN] .env 이미 존재 — 건너뜀
|
||||||
|
)
|
||||||
|
|
||||||
|
:: ---------- 6. 디렉토리 ----------
|
||||||
|
echo 6. 디렉토리 생성
|
||||||
|
if not exist logs mkdir logs
|
||||||
|
if not exist uploads mkdir uploads
|
||||||
|
if not exist backup mkdir backup
|
||||||
|
echo [OK] logs\ uploads\ backup\
|
||||||
|
|
||||||
|
:: ---------- 결과 ----------
|
||||||
|
echo.
|
||||||
|
echo ==========================================
|
||||||
|
if !ERRORS! equ 0 (
|
||||||
|
echo 설치 점검 완료
|
||||||
|
echo.
|
||||||
|
echo 다음 단계:
|
||||||
|
echo 1. .env 파일을 환경에 맞게 수정
|
||||||
|
echo 2. DB 생성 (또는 docker compose -f docker-compose-dev.yml up -d)
|
||||||
|
echo 3. gradlew.bat bootRun
|
||||||
|
echo 4. http://localhost:8080/health 확인
|
||||||
|
) else (
|
||||||
|
echo 오류 !ERRORS!건 — 위의 [FAIL] 항목을 해결하세요
|
||||||
|
)
|
||||||
|
echo ==========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
endlocal
|
||||||
148
scripts/install.sh
일반 파일
148
scripts/install.sh
일반 파일
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================
|
||||||
|
# WBX Spring Core — Linux/macOS 설치 스크립트
|
||||||
|
# 사용법: chmod +x scripts/install.sh && ./scripts/install.sh
|
||||||
|
# ============================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------- 색상 ----------
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
|
ok() { echo -e " ${GREEN}[OK]${NC} $1"; }
|
||||||
|
warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
fail() { echo -e " ${RED}[FAIL]${NC} $1"; }
|
||||||
|
info() { echo -e " ${CYAN}[INFO]${NC} $1"; }
|
||||||
|
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " WBX Spring Core — 설치 점검"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------- 1. JDK 21 ----------
|
||||||
|
echo "1. JDK 확인"
|
||||||
|
if command -v java &>/dev/null; then
|
||||||
|
JAVA_VER=$(java -version 2>&1 | head -1 | awk -F '"' '{print $2}' | cut -d. -f1)
|
||||||
|
if [ "$JAVA_VER" -ge 21 ] 2>/dev/null; then
|
||||||
|
ok "JDK $JAVA_VER"
|
||||||
|
else
|
||||||
|
fail "JDK $JAVA_VER (21 이상 필요)"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "java 명령어 없음 — JDK 21 설치 필요"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 2. Git ----------
|
||||||
|
echo "2. Git 확인"
|
||||||
|
if command -v git &>/dev/null; then
|
||||||
|
ok "$(git --version)"
|
||||||
|
else
|
||||||
|
fail "git 없음"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 3. Docker (선택) ----------
|
||||||
|
echo "3. Docker 확인 (선택)"
|
||||||
|
if command -v docker &>/dev/null; then
|
||||||
|
ok "$(docker --version | head -1)"
|
||||||
|
else
|
||||||
|
warn "Docker 미설치 — DB/Redis를 직접 설치해야 합니다"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 4. 빌드 ----------
|
||||||
|
echo "4. Gradle 빌드"
|
||||||
|
if [ $ERRORS -gt 0 ]; then
|
||||||
|
fail "사전 요구사항 미충족 — 빌드 건너뜀"
|
||||||
|
else
|
||||||
|
chmod +x gradlew
|
||||||
|
if ./gradlew build -x test --console=plain -q; then
|
||||||
|
ok "BUILD SUCCESSFUL"
|
||||||
|
else
|
||||||
|
fail "빌드 실패"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 5. .env 템플릿 ----------
|
||||||
|
echo "5. 환경변수 파일"
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cat > .env << 'ENVEOF'
|
||||||
|
# ===== WBX Spring Core — 환경변수 =====
|
||||||
|
# 이 파일을 환경에 맞게 수정하세요.
|
||||||
|
|
||||||
|
# --- 프로필 ---
|
||||||
|
SPRING_PROFILES_ACTIVE=prod,mysql
|
||||||
|
|
||||||
|
# --- 서버 ---
|
||||||
|
SERVER_CONTEXT_PATH=/
|
||||||
|
|
||||||
|
# --- JWT (필수 변경!) ---
|
||||||
|
JWT_SECRET=your-production-secret-key-minimum-256-bits-long
|
||||||
|
|
||||||
|
# --- DB ---
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=wbx_spring
|
||||||
|
DB_USER=wbxapp
|
||||||
|
DB_PASS=StrongP@ss
|
||||||
|
|
||||||
|
# --- Redis ---
|
||||||
|
SPRING_DATA_REDIS_HOST=localhost
|
||||||
|
|
||||||
|
# --- CORS ---
|
||||||
|
CORS_ORIGINS=https://app.company.com
|
||||||
|
|
||||||
|
# --- 로그 경로 ---
|
||||||
|
LOG_PATH=/opt/wbx-app/logs/app.log
|
||||||
|
|
||||||
|
# --- Azure SSO (azure 프로필 사용 시) ---
|
||||||
|
# AZURE_CLIENT_ID=
|
||||||
|
# AZURE_CLIENT_SECRET=
|
||||||
|
# AZURE_TENANT_ID=
|
||||||
|
|
||||||
|
# --- Azure Blob Storage ---
|
||||||
|
# AZURE_STORAGE_ACCOUNT=
|
||||||
|
# AZURE_STORAGE_KEY=
|
||||||
|
# AZURE_CONTAINER=uploads
|
||||||
|
|
||||||
|
# --- AWS Cognito (aws 프로필 사용 시) ---
|
||||||
|
# AWS_COGNITO_CLIENT_ID=
|
||||||
|
# AWS_COGNITO_CLIENT_SECRET=
|
||||||
|
# AWS_USER_POOL_ID=
|
||||||
|
# AWS_REGION=ap-northeast-2
|
||||||
|
|
||||||
|
# --- AWS S3 ---
|
||||||
|
# AWS_S3_BUCKET=
|
||||||
|
# AWS_ACCESS_KEY=
|
||||||
|
# AWS_SECRET_KEY=
|
||||||
|
ENVEOF
|
||||||
|
chmod 600 .env
|
||||||
|
ok ".env 생성 완료 (값을 수정하세요)"
|
||||||
|
else
|
||||||
|
warn ".env 이미 존재 — 건너뜀"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 6. 디렉토리 ----------
|
||||||
|
echo "6. 디렉토리 생성"
|
||||||
|
mkdir -p logs uploads backup
|
||||||
|
ok "logs/ uploads/ backup/"
|
||||||
|
|
||||||
|
# ---------- 결과 ----------
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
if [ $ERRORS -eq 0 ]; then
|
||||||
|
echo -e " ${GREEN}설치 점검 완료${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 다음 단계:"
|
||||||
|
echo " 1. .env 파일을 환경에 맞게 수정"
|
||||||
|
echo " 2. DB 생성 (또는 docker compose -f docker-compose-dev.yml up -d)"
|
||||||
|
echo " 3. ./gradlew bootRun"
|
||||||
|
echo " 4. http://localhost:8080/health 확인"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}오류 ${ERRORS}건 — 위의 [FAIL] 항목을 해결하세요${NC}"
|
||||||
|
fi
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
1
settings.gradle
일반 파일
1
settings.gradle
일반 파일
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'wbx-spring-core'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.co.accura.wbx.spring;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class HealthController {
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public Map<String, String> health() {
|
||||||
|
return Map.of("status", "ok", "app", "wbx-spring");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package kr.co.accura.wbx.spring;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableJpaAuditing
|
||||||
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
|
@EnableCaching
|
||||||
|
public class WbxSpringCoreApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(WbxSpringCoreApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package kr.co.accura.wbx.spring.admin;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.auth.*;
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.common.NotFoundException;
|
||||||
|
import kr.co.accura.wbx.spring.rbac.WbxRole;
|
||||||
|
import kr.co.accura.wbx.spring.rbac.RolePermission;
|
||||||
|
import kr.co.accura.wbx.spring.rbac.RolePermissionRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("${wbx.spring.api-prefix:/api}/admin")
|
||||||
|
@PreAuthorize("hasRole('SA')")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminController {
|
||||||
|
|
||||||
|
private final WbxUserRepository userRepository;
|
||||||
|
private final LoginHistoryRepository loginHistoryRepository;
|
||||||
|
private final WbxRoleRepository roleRepository;
|
||||||
|
private final RolePermissionRepository permissionRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
// ===== 사용자 관리 =====
|
||||||
|
|
||||||
|
@GetMapping("/users")
|
||||||
|
public Map<String, Object> listUsers(@RequestParam(defaultValue = "0") int skip,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
Page<WbxUser> page = userRepository.findAll(PageRequest.of(skip / Math.max(limit, 1), limit));
|
||||||
|
return Map.of("items", page.getContent(), "total", page.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/users/{id}")
|
||||||
|
public WbxUser getUser(@PathVariable Long id) {
|
||||||
|
return userRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/unlock")
|
||||||
|
public Map<String, String> unlockUser(@PathVariable Long id) {
|
||||||
|
WbxUser user = userRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||||
|
user.setFailedLoginAttempts(0);
|
||||||
|
user.setLastFailedLogin(null);
|
||||||
|
user.setLockedUntil(null);
|
||||||
|
userRepository.save(user);
|
||||||
|
return Map.of("detail", "User unlocked");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/reset-password")
|
||||||
|
public Map<String, String> resetPassword(@PathVariable Long id) {
|
||||||
|
WbxUser user = userRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||||
|
String tempPwd = "Temp" + System.currentTimeMillis() % 10000 + "!";
|
||||||
|
user.setHashedPassword(passwordEncoder.encode(tempPwd));
|
||||||
|
user.setMustChangePassword(true);
|
||||||
|
userRepository.save(user);
|
||||||
|
return Map.of("detail", "Password reset", "temp_password", tempPwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/users/{id}/status")
|
||||||
|
public Map<String, String> toggleStatus(@PathVariable Long id, @RequestBody Map<String, Boolean> req) {
|
||||||
|
WbxUser user = userRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||||
|
user.setActive(req.getOrDefault("is_active", true));
|
||||||
|
userRepository.save(user);
|
||||||
|
return Map.of("detail", "Status updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 역할 관리 =====
|
||||||
|
|
||||||
|
@GetMapping("/roles")
|
||||||
|
public Map<String, Object> listRoles() {
|
||||||
|
List<WbxRole> roles = roleRepository.findAll();
|
||||||
|
return Map.of("items", roles, "total", roles.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/roles")
|
||||||
|
public WbxRole createRole(@RequestBody WbxRole role) {
|
||||||
|
return roleRepository.save(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/roles/{id}/permissions")
|
||||||
|
public Map<String, Object> getRolePermissions(@PathVariable Long id) {
|
||||||
|
List<RolePermission> perms = permissionRepository.findAll()
|
||||||
|
.stream().filter(p -> p.getRoleId().equals(id)).toList();
|
||||||
|
return Map.of("items", perms, "total", perms.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 로그인 이력 =====
|
||||||
|
|
||||||
|
@GetMapping("/login-history")
|
||||||
|
public Map<String, Object> loginHistory(@RequestParam(defaultValue = "0") int skip,
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
Page<WbxLoginHistory> page = loginHistoryRepository.findAllByOrderByCreatedAtDesc(
|
||||||
|
PageRequest.of(skip / Math.max(limit, 1), limit));
|
||||||
|
return Map.of("items", page.getContent(), "total", page.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 시스템 상태 =====
|
||||||
|
|
||||||
|
@GetMapping("/system-health")
|
||||||
|
public Map<String, Object> systemHealth() {
|
||||||
|
return Map.of(
|
||||||
|
"users", userRepository.countByIsActiveTrue(),
|
||||||
|
"status", "ok"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.accura.wbx.spring.admin;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class AdminLoginController {
|
||||||
|
|
||||||
|
@GetMapping("/admin/login")
|
||||||
|
public String loginPage() {
|
||||||
|
return "admin/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package kr.co.accura.wbx.spring.admin;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.auth.WbxUserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final WbxUserRepository userRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
|
var user = userRepository.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
|
||||||
|
|
||||||
|
return new User(
|
||||||
|
user.getEmail(),
|
||||||
|
user.getHashedPassword(),
|
||||||
|
user.isActive(),
|
||||||
|
true, true,
|
||||||
|
!user.isLocked(),
|
||||||
|
List.of(new SimpleGrantedAuthority(user.isAdmin() ? "ROLE_SA" : "ROLE_USER"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package kr.co.accura.wbx.spring.admin;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.auth.*;
|
||||||
|
import kr.co.accura.wbx.spring.audit.AuditLogRepository;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSystemConfig;
|
||||||
|
import kr.co.accura.wbx.spring.rbac.*;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
|
import java.lang.management.MemoryMXBean;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminViewController {
|
||||||
|
|
||||||
|
private final WbxUserRepository userRepository;
|
||||||
|
private final LoginHistoryRepository loginHistoryRepository;
|
||||||
|
private final WbxRoleRepository roleRepository;
|
||||||
|
private final RolePermissionRepository permissionRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final AuditLogRepository auditLogRepository;
|
||||||
|
private final WbxSystemConfigRepository systemConfigRepository;
|
||||||
|
private final WbxUserRoleRepository userRoleRepository;
|
||||||
|
|
||||||
|
// ===== 대시보드 =====
|
||||||
|
@GetMapping
|
||||||
|
public String dashboard(Model model) {
|
||||||
|
model.addAttribute("userCount", userRepository.countByIsActiveTrue());
|
||||||
|
model.addAttribute("totalUsers", userRepository.count());
|
||||||
|
model.addAttribute("loginCount", loginHistoryRepository.countByAction("LOGIN_SUCCESS"));
|
||||||
|
model.addAttribute("roleCount", roleRepository.count());
|
||||||
|
return "admin/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 사용자 관리 =====
|
||||||
|
@GetMapping("/users")
|
||||||
|
public String userList(Model model) {
|
||||||
|
model.addAttribute("users", userRepository.findAll());
|
||||||
|
return "admin/users";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/add")
|
||||||
|
public String addUser(@RequestParam String email,
|
||||||
|
@RequestParam String username,
|
||||||
|
@RequestParam String password,
|
||||||
|
@RequestParam(required = false) String fullName,
|
||||||
|
@RequestParam(required = false) String phone,
|
||||||
|
@RequestParam(required = false) String positionTitle,
|
||||||
|
@RequestParam(required = false) String employeeNumber,
|
||||||
|
@RequestParam(required = false) Boolean isAdmin,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
if (userRepository.existsByEmail(email)) {
|
||||||
|
ra.addFlashAttribute("error", "이미 존재하는 이메일: " + email);
|
||||||
|
return "redirect:/admin/users";
|
||||||
|
}
|
||||||
|
if (userRepository.existsByUsername(username)) {
|
||||||
|
ra.addFlashAttribute("error", "이미 존재하는 사용자명: " + username);
|
||||||
|
return "redirect:/admin/users";
|
||||||
|
}
|
||||||
|
WbxUser user = WbxUser.builder()
|
||||||
|
.email(email)
|
||||||
|
.username(username)
|
||||||
|
.hashedPassword(passwordEncoder.encode(password))
|
||||||
|
.fullName(fullName)
|
||||||
|
.phone(phone)
|
||||||
|
.positionTitle(positionTitle)
|
||||||
|
.employeeNumber(employeeNumber != null && !employeeNumber.isBlank() ? employeeNumber : null)
|
||||||
|
.isActive(true)
|
||||||
|
.isAdmin(isAdmin != null && isAdmin)
|
||||||
|
.build();
|
||||||
|
userRepository.save(user);
|
||||||
|
ra.addFlashAttribute("message", "사용자가 추가되었습니다: " + email);
|
||||||
|
return "redirect:/admin/users/" + user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/users/{id}")
|
||||||
|
public String userDetail(@PathVariable Long id, Model model) {
|
||||||
|
model.addAttribute("user", userRepository.findById(id).orElse(null));
|
||||||
|
model.addAttribute("loginHistory",
|
||||||
|
loginHistoryRepository.findByUserIdOrderByCreatedAtDesc(id, PageRequest.of(0, 10)));
|
||||||
|
model.addAttribute("allRoles", roleRepository.findAll());
|
||||||
|
model.addAttribute("userRoles", userRoleRepository.findByUserId(id));
|
||||||
|
return "admin/user-detail";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/update")
|
||||||
|
public String updateUser(@PathVariable Long id,
|
||||||
|
@RequestParam String fullName,
|
||||||
|
@RequestParam(required = false) String phone,
|
||||||
|
@RequestParam(required = false) String positionTitle,
|
||||||
|
@RequestParam(required = false) String employeeNumber,
|
||||||
|
@RequestParam(required = false) Boolean isAdmin,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
userRepository.findById(id).ifPresent(user -> {
|
||||||
|
user.setFullName(fullName);
|
||||||
|
user.setPhone(phone);
|
||||||
|
user.setPositionTitle(positionTitle);
|
||||||
|
user.setEmployeeNumber(employeeNumber != null && !employeeNumber.isBlank() ? employeeNumber : null);
|
||||||
|
user.setAdmin(isAdmin != null && isAdmin);
|
||||||
|
userRepository.save(user);
|
||||||
|
});
|
||||||
|
ra.addFlashAttribute("message", "사용자 정보가 수정되었습니다.");
|
||||||
|
return "redirect:/admin/users/" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/delete")
|
||||||
|
public String deleteUser(@PathVariable Long id, RedirectAttributes ra) {
|
||||||
|
userRepository.findById(id).ifPresent(user -> {
|
||||||
|
userRoleRepository.deleteByUserId(id);
|
||||||
|
userRepository.delete(user);
|
||||||
|
ra.addFlashAttribute("message", "사용자가 삭제되었습니다: " + user.getEmail());
|
||||||
|
});
|
||||||
|
return "redirect:/admin/users";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/unlock")
|
||||||
|
public String unlockUser(@PathVariable Long id, RedirectAttributes ra) {
|
||||||
|
userRepository.findById(id).ifPresent(user -> {
|
||||||
|
user.setFailedLoginAttempts(0);
|
||||||
|
user.setLockedUntil(null);
|
||||||
|
userRepository.save(user);
|
||||||
|
});
|
||||||
|
ra.addFlashAttribute("message", "계정 잠금이 해제되었습니다.");
|
||||||
|
return "redirect:/admin/users/" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/reset-password")
|
||||||
|
public String resetPassword(@PathVariable Long id, RedirectAttributes ra) {
|
||||||
|
userRepository.findById(id).ifPresent(user -> {
|
||||||
|
String temp = "Temp" + System.currentTimeMillis() % 10000 + "!";
|
||||||
|
user.setHashedPassword(passwordEncoder.encode(temp));
|
||||||
|
user.setMustChangePassword(true);
|
||||||
|
userRepository.save(user);
|
||||||
|
ra.addFlashAttribute("message", "임시 비밀번호: " + temp);
|
||||||
|
});
|
||||||
|
return "redirect:/admin/users/" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/toggle-status")
|
||||||
|
public String toggleStatus(@PathVariable Long id, RedirectAttributes ra) {
|
||||||
|
userRepository.findById(id).ifPresent(user -> {
|
||||||
|
user.setActive(!user.isActive());
|
||||||
|
userRepository.save(user);
|
||||||
|
ra.addFlashAttribute("message", user.isActive() ? "계정 활성화" : "계정 비활성화");
|
||||||
|
});
|
||||||
|
return "redirect:/admin/users/" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 사용자 역할 할당 =====
|
||||||
|
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||||
|
@PostMapping("/users/{userId}/roles/add")
|
||||||
|
public String addUserRole(@PathVariable Long userId,
|
||||||
|
@RequestParam Long roleId,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
WbxUserRole ur = WbxUserRole.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.roleId(roleId)
|
||||||
|
.build();
|
||||||
|
userRoleRepository.save(ur);
|
||||||
|
ra.addFlashAttribute("message", "역할이 할당되었습니다.");
|
||||||
|
return "redirect:/admin/users/" + userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||||
|
@PostMapping("/users/{userId}/roles/{urId}/delete")
|
||||||
|
public String removeUserRole(@PathVariable Long userId,
|
||||||
|
@PathVariable Long urId,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
userRoleRepository.deleteById(urId);
|
||||||
|
ra.addFlashAttribute("message", "역할이 해제되었습니다.");
|
||||||
|
return "redirect:/admin/users/" + userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 역할 관리 =====
|
||||||
|
@GetMapping("/roles")
|
||||||
|
public String roleList(Model model) {
|
||||||
|
model.addAttribute("roles", roleRepository.findAll());
|
||||||
|
return "admin/roles";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/roles/add")
|
||||||
|
public String addRole(@RequestParam String code,
|
||||||
|
@RequestParam String name,
|
||||||
|
@RequestParam(required = false) String description,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
if (roleRepository.findByCode(code).isPresent()) {
|
||||||
|
ra.addFlashAttribute("error", "이미 존재하는 역할 코드: " + code);
|
||||||
|
return "redirect:/admin/roles";
|
||||||
|
}
|
||||||
|
kr.co.accura.wbx.spring.rbac.WbxRole role = kr.co.accura.wbx.spring.rbac.WbxRole.builder()
|
||||||
|
.code(code.toUpperCase())
|
||||||
|
.name(name)
|
||||||
|
.description(description)
|
||||||
|
.isSystem(false)
|
||||||
|
.build();
|
||||||
|
roleRepository.save(role);
|
||||||
|
ra.addFlashAttribute("message", "역할이 추가되었습니다: " + code);
|
||||||
|
return "redirect:/admin/roles";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/roles/{id}")
|
||||||
|
public String roleDetail(@PathVariable Long id, Model model) {
|
||||||
|
model.addAttribute("role", roleRepository.findById(id).orElse(null));
|
||||||
|
model.addAttribute("permissions", permissionRepository.findByRoleId(id));
|
||||||
|
model.addAttribute("deptScopes", kr.co.accura.wbx.spring.rbac.DeptScope.values());
|
||||||
|
return "admin/role-detail";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/roles/{id}/update")
|
||||||
|
public String updateRole(@PathVariable Long id,
|
||||||
|
@RequestParam String name,
|
||||||
|
@RequestParam(required = false) String description,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
roleRepository.findById(id).ifPresent(role -> {
|
||||||
|
role.setName(name);
|
||||||
|
role.setDescription(description);
|
||||||
|
roleRepository.save(role);
|
||||||
|
});
|
||||||
|
ra.addFlashAttribute("message", "역할이 수정되었습니다.");
|
||||||
|
return "redirect:/admin/roles/" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/roles/{id}/delete")
|
||||||
|
public String deleteRole(@PathVariable Long id, RedirectAttributes ra) {
|
||||||
|
roleRepository.findById(id).ifPresent(role -> {
|
||||||
|
if (role.isSystem()) {
|
||||||
|
ra.addFlashAttribute("error", "시스템 역할은 삭제할 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
permissionRepository.deleteByRoleId(id);
|
||||||
|
roleRepository.delete(role);
|
||||||
|
ra.addFlashAttribute("message", "역할이 삭제되었습니다: " + role.getCode());
|
||||||
|
});
|
||||||
|
return "redirect:/admin/roles";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 권한 추가/삭제 =====
|
||||||
|
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||||
|
@PostMapping("/roles/{roleId}/permissions/add")
|
||||||
|
public String addPermission(@PathVariable Long roleId,
|
||||||
|
@RequestParam String module,
|
||||||
|
@RequestParam String action,
|
||||||
|
@RequestParam String deptScope,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
RolePermission perm = RolePermission.builder()
|
||||||
|
.roleId(roleId)
|
||||||
|
.module(module.toUpperCase())
|
||||||
|
.action(action.toUpperCase())
|
||||||
|
.deptScope(kr.co.accura.wbx.spring.rbac.DeptScope.valueOf(deptScope))
|
||||||
|
.build();
|
||||||
|
permissionRepository.save(perm);
|
||||||
|
ra.addFlashAttribute("message", "권한이 추가되었습니다: " + module + "." + action);
|
||||||
|
return "redirect:/admin/roles/" + roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CacheEvict(value = {"permissions", "deptScopes"}, allEntries = true)
|
||||||
|
@PostMapping("/permissions/{id}/delete")
|
||||||
|
public String deletePermission(@PathVariable Long id, RedirectAttributes ra) {
|
||||||
|
RolePermission perm = permissionRepository.findById(id).orElse(null);
|
||||||
|
if (perm != null) {
|
||||||
|
Long roleId = perm.getRoleId();
|
||||||
|
permissionRepository.delete(perm);
|
||||||
|
ra.addFlashAttribute("message", "권한이 삭제되었습니다.");
|
||||||
|
return "redirect:/admin/roles/" + roleId;
|
||||||
|
}
|
||||||
|
return "redirect:/admin/permissions";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 로그인 이력 =====
|
||||||
|
@GetMapping("/login-history")
|
||||||
|
public String loginHistory(Model model) {
|
||||||
|
model.addAttribute("logs",
|
||||||
|
loginHistoryRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, 50)));
|
||||||
|
return "admin/login-history";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 감사 로그 =====
|
||||||
|
@GetMapping("/audit-logs")
|
||||||
|
public String auditLogs(Model model) {
|
||||||
|
model.addAttribute("logs",
|
||||||
|
auditLogRepository.findAll(PageRequest.of(0, 100, Sort.by(Sort.Direction.DESC, "createdAt"))));
|
||||||
|
return "admin/audit-logs";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 시스템 설정 =====
|
||||||
|
@GetMapping("/config")
|
||||||
|
public String systemConfig(Model model) {
|
||||||
|
model.addAttribute("configs", systemConfigRepository.findAll());
|
||||||
|
return "admin/config";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/config/save")
|
||||||
|
public String saveConfig(@RequestParam String configKey,
|
||||||
|
@RequestParam String configValue,
|
||||||
|
@RequestParam(required = false) String description,
|
||||||
|
RedirectAttributes ra) {
|
||||||
|
WbxSystemConfig config = systemConfigRepository.findByConfigKey(configKey)
|
||||||
|
.orElse(new WbxSystemConfig());
|
||||||
|
config.setConfigKey(configKey);
|
||||||
|
config.setConfigValue(configValue);
|
||||||
|
config.setDescription(description);
|
||||||
|
systemConfigRepository.save(config);
|
||||||
|
ra.addFlashAttribute("message", "설정이 저장되었습니다: " + configKey);
|
||||||
|
return "redirect:/admin/config";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 권한 매트릭스 =====
|
||||||
|
@GetMapping("/permissions")
|
||||||
|
public String permissions(Model model) {
|
||||||
|
model.addAttribute("roles", roleRepository.findAll());
|
||||||
|
model.addAttribute("permissions", permissionRepository.findAll());
|
||||||
|
return "admin/permissions";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 시스템 상태 =====
|
||||||
|
@GetMapping("/system-health")
|
||||||
|
public String systemHealth(Model model) {
|
||||||
|
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
|
||||||
|
long heapUsed = mem.getHeapMemoryUsage().getUsed() / (1024 * 1024);
|
||||||
|
long heapMax = mem.getHeapMemoryUsage().getMax() / (1024 * 1024);
|
||||||
|
|
||||||
|
Map<String, Object> health = new LinkedHashMap<>();
|
||||||
|
health.put("JVM Heap", heapUsed + " MB / " + heapMax + " MB");
|
||||||
|
health.put("Active Threads", Thread.activeCount());
|
||||||
|
health.put("OS", System.getProperty("os.name") + " " + System.getProperty("os.arch"));
|
||||||
|
health.put("Java", System.getProperty("java.version"));
|
||||||
|
health.put("Spring Boot", org.springframework.boot.SpringBootVersion.getVersion());
|
||||||
|
health.put("Total Users", userRepository.count());
|
||||||
|
health.put("Active Users", userRepository.countByIsActiveTrue());
|
||||||
|
|
||||||
|
model.addAttribute("health", health);
|
||||||
|
return "admin/system-health";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.accura.wbx.spring.admin;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.rbac.WbxRole;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WbxRoleRepository extends JpaRepository<WbxRole, Long> {
|
||||||
|
Optional<WbxRole> findByCode(String code);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.accura.wbx.spring.admin;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSystemConfig;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WbxSystemConfigRepository extends JpaRepository<WbxSystemConfig, Long> {
|
||||||
|
Optional<WbxSystemConfig> findByConfigKey(String configKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
public record ActionRequest(String comment) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결재 완료 이벤트 — @EventListener로 후속 처리
|
||||||
|
*/
|
||||||
|
public record ApprovalCompletedEvent(
|
||||||
|
String approvalType,
|
||||||
|
Long itemId,
|
||||||
|
Long approverId,
|
||||||
|
Object approval
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결재 유형별 핸들러 인터페이스
|
||||||
|
* @Component로 등록하면 ApprovalHandlerRegistry가 자동 수집
|
||||||
|
*/
|
||||||
|
public interface ApprovalHandler {
|
||||||
|
|
||||||
|
/** 유형 키 (URL path에 사용) — e.g. "timesheet", "project" */
|
||||||
|
String getTypeKey();
|
||||||
|
|
||||||
|
/** 표시명 — e.g. "시수 결재" */
|
||||||
|
String getTypeDisplay();
|
||||||
|
|
||||||
|
/** 승인 처리 */
|
||||||
|
ApprovalResult approve(Long approvalLineId, Long approverId, String comment);
|
||||||
|
|
||||||
|
/** 반려 처리 */
|
||||||
|
ApprovalResult reject(Long approvalLineId, Long approverId, String comment);
|
||||||
|
|
||||||
|
/** 결재 이력 조회 (ApprovalLine.vue 호환) */
|
||||||
|
ApprovalHistoryDto getApprovalHistory(Long itemId);
|
||||||
|
|
||||||
|
/** 결재 대기 목록 */
|
||||||
|
Page<ApprovalPendingDto> getPending(Long approverId, Pageable pageable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결재 핸들러 레지스트리 — Spring이 모든 ApprovalHandler 구현체를 자동 수집
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class ApprovalHandlerRegistry {
|
||||||
|
|
||||||
|
private final Map<String, ApprovalHandler> handlers;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ApprovalHandlerRegistry(List<ApprovalHandler> handlerList) {
|
||||||
|
this.handlers = handlerList.stream()
|
||||||
|
.collect(Collectors.toMap(ApprovalHandler::getTypeKey, Function.identity()));
|
||||||
|
log.info("Registered approval handlers: {}", handlers.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApprovalHandler get(String typeKey) {
|
||||||
|
return Optional.ofNullable(handlers.get(typeKey))
|
||||||
|
.orElseThrow(() -> new BusinessException("Unknown approval type: " + typeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection<ApprovalHandler> getAll() {
|
||||||
|
return handlers.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasHandler(String typeKey) {
|
||||||
|
return handlers.containsKey(typeKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ApprovalHistoryDto(
|
||||||
|
Long itemId,
|
||||||
|
String approvalType,
|
||||||
|
String status,
|
||||||
|
String authorName,
|
||||||
|
LocalDateTime submittedAt,
|
||||||
|
List<ApprovalLineDto> approvalLines
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record ApprovalLineDto(
|
||||||
|
Long id, Long approverId, String approverName,
|
||||||
|
int approvalOrder, String roleCode, String status,
|
||||||
|
String comment, LocalDateTime actedAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record ApprovalPendingDto(
|
||||||
|
Long id, String approvalType, String title,
|
||||||
|
String requesterName, LocalDateTime submittedAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
public record ApprovalResult(boolean success, String message, Object data) {
|
||||||
|
|
||||||
|
public static ApprovalResult success(String message) {
|
||||||
|
return new ApprovalResult(true, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApprovalResult success(String message, Object data) {
|
||||||
|
return new ApprovalResult(true, message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApprovalResult failure(String message) {
|
||||||
|
return new ApprovalResult(false, message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package kr.co.accura.wbx.spring.approval;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.SecurityUtils;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통합 결재 API — WBX 패턴 이식
|
||||||
|
* POST /api/approvals/unified/action/{type}/{id}/approve
|
||||||
|
* POST /api/approvals/unified/action/{type}/{id}/reject
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("${wbx.spring.api-prefix:/api}/approvals/unified")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UnifiedApprovalController {
|
||||||
|
|
||||||
|
private final ApprovalHandlerRegistry registry;
|
||||||
|
|
||||||
|
@PostMapping("/action/{type}/{id}/approve")
|
||||||
|
public ApprovalResult approve(@PathVariable String type,
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody(required = false) ActionRequest req) {
|
||||||
|
return registry.get(type)
|
||||||
|
.approve(id, SecurityUtils.getCurrentUserId(),
|
||||||
|
req != null ? req.comment() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/action/{type}/{id}/reject")
|
||||||
|
public ApprovalResult reject(@PathVariable String type,
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody ActionRequest req) {
|
||||||
|
return registry.get(type)
|
||||||
|
.reject(id, SecurityUtils.getCurrentUserId(), req.comment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public Map<String, Object> pending(@RequestParam(defaultValue = "0") int skip,
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
Long userId = SecurityUtils.getCurrentUserId();
|
||||||
|
List<ApprovalPendingDto> all = registry.getAll().stream()
|
||||||
|
.flatMap(h -> h.getPending(userId, PageRequest.of(0, 100)).getContent().stream())
|
||||||
|
.sorted(Comparator.comparing(ApprovalPendingDto::submittedAt).reversed())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int end = Math.min(skip + limit, all.size());
|
||||||
|
return Map.of(
|
||||||
|
"items", skip < all.size() ? all.subList(skip, end) : List.of(),
|
||||||
|
"total", all.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.accura.wbx.spring.audit;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface AuditLogRepository extends JpaRepository<WbxAuditLog, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.co.accura.wbx.spring.audit;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.SecurityUtils;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditLogService {
|
||||||
|
|
||||||
|
private final AuditLogRepository repository;
|
||||||
|
|
||||||
|
public void log(String action, String resource, Long resourceId, String detail) {
|
||||||
|
repository.save(WbxAuditLog.builder()
|
||||||
|
.userId(SecurityUtils.getCurrentUserId())
|
||||||
|
.username(SecurityUtils.getCurrentUsername())
|
||||||
|
.action(action)
|
||||||
|
.resource(resource)
|
||||||
|
.resourceId(resourceId)
|
||||||
|
.detail(detail)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.co.accura.wbx.spring.audit;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_audit_logs")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxAuditLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String resource;
|
||||||
|
|
||||||
|
private Long resourceId;
|
||||||
|
|
||||||
|
@Column(length = 4000)
|
||||||
|
private String detail;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key 인증 — 서버-서버(SAP BTP, ERP 등) 연동용
|
||||||
|
* Header: X-API-Key: {key}
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class ApiKeyFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
@Value("${wbx.spring.api-keys:}")
|
||||||
|
private List<String> validApiKeys;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain chain) throws ServletException, IOException {
|
||||||
|
String apiKey = request.getHeader("X-API-Key");
|
||||||
|
if (apiKey != null && !apiKey.isBlank() && validApiKeys.contains(apiKey)) {
|
||||||
|
var auth = new UsernamePasswordAuthenticationToken(
|
||||||
|
"system", null,
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_SYSTEM"),
|
||||||
|
new SimpleGrantedAuthority("ROLE_SA")));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
log.debug("API Key authenticated: {}", apiKey.substring(0, Math.min(8, apiKey.length())) + "...");
|
||||||
|
}
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
return request.getHeader("X-API-Key") == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("${wbx.spring.api-prefix:/api}/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final WbxUserRepository userRepository;
|
||||||
|
private final JwtProvider jwtProvider;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final RefreshTokenService refreshTokenService;
|
||||||
|
private final LoginHistoryRepository loginHistoryRepository;
|
||||||
|
private final PasswordPolicy passwordPolicy;
|
||||||
|
|
||||||
|
/** MFA 서비스 — wbx.spring.mfa.enabled=true 일 때만 주입 */
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
private MfaService mfaService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 — WBX FastAPI 호환 JWT 발급
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
public Map<String, Object> login(@Valid @RequestBody LoginRequest req, HttpServletRequest httpReq) {
|
||||||
|
WbxUser user = userRepository.findByEmail(req.email())
|
||||||
|
.orElseThrow(() -> new BusinessException("Invalid email or password"));
|
||||||
|
|
||||||
|
// 계정 잠금 체크
|
||||||
|
if (user.isLocked()) {
|
||||||
|
throw new BusinessException("Account locked. Try again later.", "ACCOUNT_LOCKED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 검증
|
||||||
|
if (!passwordEncoder.matches(req.password(), user.getHashedPassword())) {
|
||||||
|
user.recordLoginFailure(
|
||||||
|
props.getPassword().getMaxFailedAttempts(),
|
||||||
|
15 // lockout minutes
|
||||||
|
);
|
||||||
|
userRepository.save(user);
|
||||||
|
throw new BusinessException("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isActive()) {
|
||||||
|
throw new BusinessException("User account is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MFA 체크 — MFA 활성 사용자는 2단계 인증 필요
|
||||||
|
if (mfaService != null && mfaService.isMfaRequired(user)) {
|
||||||
|
Map<String, Object> mfaResponse = new HashMap<>();
|
||||||
|
mfaResponse.put("mfa_required", true);
|
||||||
|
mfaResponse.put("user_id", user.getId());
|
||||||
|
mfaResponse.put("mfa_enabled", user.isMfaEnabled());
|
||||||
|
return mfaResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return completeLogin(user, httpReq, "PASSWORD");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 로그인 2단계 — TOTP 코드 검증 후 JWT 발급
|
||||||
|
*/
|
||||||
|
@PostMapping("/mfa/verify")
|
||||||
|
public Map<String, Object> mfaVerify(@Valid @RequestBody MfaVerifyRequest req, HttpServletRequest httpReq) {
|
||||||
|
if (mfaService == null) {
|
||||||
|
throw new BusinessException("MFA is not enabled");
|
||||||
|
}
|
||||||
|
WbxUser user = userRepository.findById(req.userId())
|
||||||
|
.orElseThrow(() -> new BusinessException("User not found"));
|
||||||
|
|
||||||
|
boolean valid = mfaService.verifyLogin(user.getId(), req.code());
|
||||||
|
if (!valid) {
|
||||||
|
throw new BusinessException("Invalid MFA code", "MFA_INVALID_CODE");
|
||||||
|
}
|
||||||
|
return completeLogin(user, httpReq, "MFA_TOTP");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 완료 — JWT + Refresh Token 발급
|
||||||
|
*/
|
||||||
|
private Map<String, Object> completeLogin(WbxUser user, HttpServletRequest httpReq, String authMethod) {
|
||||||
|
user.recordLoginSuccess();
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
String token = jwtProvider.generateToken(user.toUserDetails());
|
||||||
|
String ip = httpReq.getRemoteAddr();
|
||||||
|
String ua = httpReq.getHeader("User-Agent");
|
||||||
|
|
||||||
|
String refreshToken = refreshTokenService.create(user.getId(), ua, ip);
|
||||||
|
|
||||||
|
loginHistoryRepository.save(WbxLoginHistory.builder()
|
||||||
|
.userId(user.getId()).email(user.getEmail())
|
||||||
|
.action("LOGIN_SUCCESS").authMethod(authMethod)
|
||||||
|
.ipAddress(ip).userAgent(ua != null ? ua.substring(0, Math.min(ua.length(), 500)) : null)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("access_token", token);
|
||||||
|
result.put("refresh_token", refreshToken);
|
||||||
|
result.put("token_type", "bearer");
|
||||||
|
result.put("user", Map.of(
|
||||||
|
"id", user.getId(),
|
||||||
|
"email", user.getEmail(),
|
||||||
|
"username", user.getUsername(),
|
||||||
|
"full_name", user.getFullName() != null ? user.getFullName() : "",
|
||||||
|
"is_admin", user.isAdmin(),
|
||||||
|
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0
|
||||||
|
));
|
||||||
|
result.put("must_change_password", user.isMustChangePassword());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 (SA 전용 또는 초기 설정용)
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
public Map<String, Object> register(@Valid @RequestBody RegisterRequest req) {
|
||||||
|
if (userRepository.existsByEmail(req.email())) {
|
||||||
|
throw new BusinessException("Email already exists");
|
||||||
|
}
|
||||||
|
if (userRepository.existsByUsername(req.username())) {
|
||||||
|
throw new BusinessException("Username already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordPolicy.validate(req.password());
|
||||||
|
|
||||||
|
WbxUser user = WbxUser.builder()
|
||||||
|
.email(req.email())
|
||||||
|
.username(req.username())
|
||||||
|
.hashedPassword(passwordEncoder.encode(req.password()))
|
||||||
|
.fullName(req.fullName())
|
||||||
|
.isActive(true)
|
||||||
|
.isAdmin(req.isAdmin() != null && req.isAdmin())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
userRepository.save(user);
|
||||||
|
log.info("User registered: {}", user.getEmail());
|
||||||
|
|
||||||
|
return Map.of("detail", "User registered successfully", "id", user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내 정보 조회
|
||||||
|
*/
|
||||||
|
@GetMapping("/me")
|
||||||
|
public Map<String, Object> me(@AuthenticationPrincipal WbxUserDetails user) {
|
||||||
|
return Map.of(
|
||||||
|
"id", user.getId(),
|
||||||
|
"email", user.getEmail(),
|
||||||
|
"username", user.getUsername(),
|
||||||
|
"full_name", user.getFullName(),
|
||||||
|
"is_admin", user.isAdmin(),
|
||||||
|
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0,
|
||||||
|
"roles", user.getRoles()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경
|
||||||
|
*/
|
||||||
|
@PutMapping("/password/change")
|
||||||
|
public Map<String, String> changePassword(@AuthenticationPrincipal WbxUserDetails userDetails,
|
||||||
|
@Valid @RequestBody PasswordChangeRequest req) {
|
||||||
|
WbxUser user = userRepository.findById(userDetails.getId())
|
||||||
|
.orElseThrow(() -> new BusinessException("User not found"));
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(req.currentPassword(), user.getHashedPassword())) {
|
||||||
|
throw new BusinessException("Current password is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordPolicy.validate(req.newPassword());
|
||||||
|
|
||||||
|
user.setHashedPassword(passwordEncoder.encode(req.newPassword()));
|
||||||
|
user.setMustChangePassword(false);
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
return Map.of("detail", "Password changed successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access Token 갱신
|
||||||
|
*/
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public Map<String, Object> refresh(@RequestBody Map<String, String> req) {
|
||||||
|
String refreshToken = req.get("refresh_token");
|
||||||
|
if (refreshToken == null || refreshToken.isBlank()) {
|
||||||
|
throw new BusinessException("refresh_token is required");
|
||||||
|
}
|
||||||
|
String newToken = refreshTokenService.refresh(refreshToken);
|
||||||
|
return Map.of("access_token", newToken, "token_type", "bearer");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public Map<String, String> logout(@AuthenticationPrincipal WbxUserDetails user,
|
||||||
|
@RequestBody(required = false) Map<String, String> req) {
|
||||||
|
if (req != null && req.get("refresh_token") != null) {
|
||||||
|
refreshTokenService.revoke(req.get("refresh_token"));
|
||||||
|
}
|
||||||
|
if (user != null) {
|
||||||
|
loginHistoryRepository.save(WbxLoginHistory.builder()
|
||||||
|
.userId(user.getId()).email(user.getEmail())
|
||||||
|
.action("LOGOUT").authMethod("TOKEN")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return Map.of("detail", "Logged out successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Request DTOs =====
|
||||||
|
|
||||||
|
public record LoginRequest(
|
||||||
|
@NotBlank @Email String email,
|
||||||
|
@NotBlank String password
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RegisterRequest(
|
||||||
|
@NotBlank @Email String email,
|
||||||
|
@NotBlank String username,
|
||||||
|
@NotBlank String password,
|
||||||
|
String fullName,
|
||||||
|
Boolean isAdmin
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record PasswordChangeRequest(
|
||||||
|
@NotBlank String currentPassword,
|
||||||
|
@NotBlank String newPassword
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record MfaVerifyRequest(
|
||||||
|
Long userId,
|
||||||
|
@NotBlank String code
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtProvider jwtProvider;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain chain) throws ServletException, IOException {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
|
String token = header.substring(7);
|
||||||
|
try {
|
||||||
|
Claims claims = jwtProvider.parseToken(token);
|
||||||
|
|
||||||
|
// MFA 토큰은 인증 불가
|
||||||
|
if (jwtProvider.isMfaToken(claims)) {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = claims.get("user_id", Long.class);
|
||||||
|
String email = claims.get("email", String.class);
|
||||||
|
String username = claims.get("username", String.class);
|
||||||
|
String fullName = claims.get("full_name", String.class);
|
||||||
|
Boolean isAdmin = claims.get("is_admin", Boolean.class);
|
||||||
|
Long deptId = claims.get("department_id", Long.class);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> roles = claims.get("roles", List.class);
|
||||||
|
|
||||||
|
WbxUserDetails user = WbxUserDetails.builder()
|
||||||
|
.id(userId)
|
||||||
|
.email(email != null ? email : "")
|
||||||
|
.username(username != null ? username : "")
|
||||||
|
.fullName(fullName != null ? fullName : "")
|
||||||
|
.isAdmin(Boolean.TRUE.equals(isAdmin))
|
||||||
|
.departmentId(deptId)
|
||||||
|
.roles(roles != null ? roles : List.of())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("JWT validation failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
return path.contains("/auth/login") || path.contains("/auth/sso")
|
||||||
|
|| path.equals("/health") || path.startsWith("/actuator");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtProvider {
|
||||||
|
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WBX FastAPI 호환 JWT 생성
|
||||||
|
* 필수 claims: sub(email), user_id — WBX가 이 두 필드로 사용자 식별
|
||||||
|
*/
|
||||||
|
public String generateToken(WbxUserDetails user) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.claims(Map.of(
|
||||||
|
"sub", user.getEmail(),
|
||||||
|
"user_id", user.getId(),
|
||||||
|
"username", user.getUsername(),
|
||||||
|
"full_name", user.getFullName(),
|
||||||
|
"email", user.getEmail(),
|
||||||
|
"is_admin", user.isAdmin(),
|
||||||
|
"department_id", user.getDepartmentId() != null ? user.getDepartmentId() : 0,
|
||||||
|
"roles", user.getRoles() != null ? user.getRoles() : List.of()
|
||||||
|
))
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + props.getJwt().getExpiration() * 1000))
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 임시 토큰 (5분, API 접근 불가)
|
||||||
|
*/
|
||||||
|
public String generateMfaToken(WbxUserDetails user) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.claims(Map.of(
|
||||||
|
"user_id", user.getId(),
|
||||||
|
"mfa_pending", true
|
||||||
|
))
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + 300_000)) // 5 minutes
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 검증 후 claims 추출
|
||||||
|
*/
|
||||||
|
public Claims parseToken(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 토큰인지 확인
|
||||||
|
*/
|
||||||
|
public boolean isMfaToken(Claims claims) {
|
||||||
|
return Boolean.TRUE.equals(claims.get("mfa_pending", Boolean.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SecretKey getSigningKey() {
|
||||||
|
byte[] keyBytes = props.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
|
||||||
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface LoginHistoryRepository extends JpaRepository<WbxLoginHistory, Long> {
|
||||||
|
|
||||||
|
Page<WbxLoginHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
|
||||||
|
|
||||||
|
Page<WbxLoginHistory> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||||
|
|
||||||
|
long countByAction(String action);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA/TOTP REST API
|
||||||
|
* <p>
|
||||||
|
* wbx.spring.mfa.enabled=true 일 때 활성화.
|
||||||
|
* Google Authenticator / MS Authenticator 호환 TOTP.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("${wbx.spring.api-prefix:/api}/auth/mfa")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.mfa.enabled", havingValue = "true")
|
||||||
|
public class MfaController {
|
||||||
|
|
||||||
|
private final MfaService mfaService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 설정 시작 — QR 코드용 시크릿 + otpauth URI 반환
|
||||||
|
*/
|
||||||
|
@PostMapping("/setup")
|
||||||
|
public Map<String, String> setup(@AuthenticationPrincipal WbxUserDetails user) {
|
||||||
|
return mfaService.setupMfa(user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 설정 완료 — TOTP 코드 검증 후 활성화 + 백업 코드 발급
|
||||||
|
*/
|
||||||
|
@PostMapping("/setup/verify")
|
||||||
|
public Map<String, Object> verifySetup(@AuthenticationPrincipal WbxUserDetails user,
|
||||||
|
@Valid @RequestBody CodeRequest req) {
|
||||||
|
return mfaService.verifySetup(user.getId(), req.code());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 비활성화 (본인 또는 SA)
|
||||||
|
*/
|
||||||
|
@DeleteMapping
|
||||||
|
public Map<String, String> disable(@AuthenticationPrincipal WbxUserDetails user) {
|
||||||
|
mfaService.disableMfa(user.getId());
|
||||||
|
return Map.of("detail", "MFA disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 백업 코드로 인증 (로그인 2단계 대안)
|
||||||
|
*/
|
||||||
|
@PostMapping("/backup-verify")
|
||||||
|
public Map<String, Object> backupVerify(@RequestBody Map<String, Object> req) {
|
||||||
|
Long userId = ((Number) req.get("user_id")).longValue();
|
||||||
|
String code = (String) req.get("backup_code");
|
||||||
|
boolean valid = mfaService.verifyBackupCode(userId, code);
|
||||||
|
return Map.of("valid", valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CodeRequest(@NotBlank String code) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA/TOTP 서비스 — 표준 구현
|
||||||
|
* <p>
|
||||||
|
* wbx.spring.mfa.enabled=true 일 때 활성화.
|
||||||
|
* TOTP 라이브러리 의존성이 없어도 동작하도록
|
||||||
|
* HMAC-SHA1 기반 RFC 6238 직접 구현.
|
||||||
|
* <p>
|
||||||
|
* 고객 환경에 따라 Google Authenticator / MS Authenticator 호환.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.mfa.enabled", havingValue = "true")
|
||||||
|
public class MfaService {
|
||||||
|
|
||||||
|
private final TotpSecretRepository totpRepo;
|
||||||
|
private final WbxUserRepository userRepo;
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
private static final String AES_ALGORITHM = "AES";
|
||||||
|
|
||||||
|
// ===== TOTP Setup =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 설정 시작 — QR 코드용 시크릿 + otpauth URI 반환
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, String> setupMfa(Long userId) {
|
||||||
|
WbxUser user = userRepo.findById(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException("User not found"));
|
||||||
|
|
||||||
|
// 이미 설정된 경우 기존 삭제 후 재설정
|
||||||
|
totpRepo.findByUserId(userId).ifPresent(existing -> totpRepo.delete(existing));
|
||||||
|
|
||||||
|
String secret = generateSecret();
|
||||||
|
String encrypted = encrypt(secret);
|
||||||
|
|
||||||
|
WbxTotpSecret totp = WbxTotpSecret.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.encryptedSecret(encrypted)
|
||||||
|
.verified(false)
|
||||||
|
.build();
|
||||||
|
totpRepo.save(totp);
|
||||||
|
|
||||||
|
String issuer = props.getMfa().getTotpIssuer();
|
||||||
|
String otpauthUri = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&period=%d",
|
||||||
|
issuer, user.getEmail(), secret, issuer,
|
||||||
|
props.getMfa().getTotpDigits(), props.getMfa().getTotpPeriod());
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"secret", secret,
|
||||||
|
"otpauth_uri", otpauthUri,
|
||||||
|
"qr_data", otpauthUri // 프론트엔드에서 QR 렌더링
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA 설정 확인 — 코드 검증 후 활성화 + 백업 코드 발급
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> verifySetup(Long userId, String code) {
|
||||||
|
WbxTotpSecret totp = totpRepo.findByUserId(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException("MFA not initialized. Call setup first."));
|
||||||
|
|
||||||
|
String secret = decrypt(totp.getEncryptedSecret());
|
||||||
|
if (!verifyCode(secret, code)) {
|
||||||
|
throw new BusinessException("Invalid TOTP code", "MFA_INVALID_CODE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 백업 코드 생성
|
||||||
|
List<String> backupCodes = generateBackupCodes(8);
|
||||||
|
List<String> hashedCodes = backupCodes.stream()
|
||||||
|
.map(passwordEncoder::encode)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
try {
|
||||||
|
totp.setBackupCodes(objectMapper.writeValueAsString(hashedCodes));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("Failed to save backup codes");
|
||||||
|
}
|
||||||
|
totp.setVerified(true);
|
||||||
|
totpRepo.save(totp);
|
||||||
|
|
||||||
|
// 사용자 mfaEnabled 활성화
|
||||||
|
WbxUser user = userRepo.findById(userId).orElseThrow();
|
||||||
|
user.setMfaEnabled(true);
|
||||||
|
userRepo.save(user);
|
||||||
|
|
||||||
|
log.info("MFA enabled for user: {}", user.getEmail());
|
||||||
|
return Map.of(
|
||||||
|
"detail", "MFA enabled successfully",
|
||||||
|
"backup_codes", backupCodes // 한 번만 표시 — 프론트에서 다운로드 안내
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TOTP 검증 (로그인 2단계) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 시 TOTP 코드 검증
|
||||||
|
*/
|
||||||
|
public boolean verifyLogin(Long userId, String code) {
|
||||||
|
WbxTotpSecret totp = totpRepo.findByUserId(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException("MFA not configured"));
|
||||||
|
|
||||||
|
if (!totp.isVerified()) {
|
||||||
|
throw new BusinessException("MFA setup not completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
String secret = decrypt(totp.getEncryptedSecret());
|
||||||
|
return verifyCode(secret, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 백업 코드로 로그인 (1회용)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean verifyBackupCode(Long userId, String backupCode) {
|
||||||
|
WbxTotpSecret totp = totpRepo.findByUserId(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException("MFA not configured"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<String> hashedCodes = objectMapper.readValue(
|
||||||
|
totp.getBackupCodes(), new TypeReference<List<String>>() {});
|
||||||
|
|
||||||
|
for (int i = 0; i < hashedCodes.size(); i++) {
|
||||||
|
if (passwordEncoder.matches(backupCode, hashedCodes.get(i))) {
|
||||||
|
// 사용된 코드 제거
|
||||||
|
hashedCodes.remove(i);
|
||||||
|
totp.setBackupCodes(objectMapper.writeValueAsString(hashedCodes));
|
||||||
|
totpRepo.save(totp);
|
||||||
|
log.info("Backup code used for user ID: {}", userId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to verify backup code", e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MFA 비활성화 (SA 전용) =====
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void disableMfa(Long userId) {
|
||||||
|
totpRepo.deleteByUserId(userId);
|
||||||
|
userRepo.findById(userId).ifPresent(user -> {
|
||||||
|
user.setMfaEnabled(false);
|
||||||
|
userRepo.save(user);
|
||||||
|
});
|
||||||
|
log.info("MFA disabled for user ID: {}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 MFA 필수 여부 판별
|
||||||
|
*/
|
||||||
|
public boolean isMfaRequired(WbxUser user) {
|
||||||
|
if (!props.getMfa().isEnabled()) return false;
|
||||||
|
if (user.isMfaEnabled()) return true;
|
||||||
|
// 외부 사용자 강제 MFA
|
||||||
|
if (props.getMfa().isForceForExternal() && user.getSsoProvider() == null) return true;
|
||||||
|
// 내부 사용자 강제 MFA
|
||||||
|
return props.getMfa().isForceForInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RFC 6238 TOTP 구현 =====
|
||||||
|
|
||||||
|
private boolean verifyCode(String secret, String code) {
|
||||||
|
int digits = props.getMfa().getTotpDigits();
|
||||||
|
int period = props.getMfa().getTotpPeriod();
|
||||||
|
long timeStep = System.currentTimeMillis() / 1000 / period;
|
||||||
|
|
||||||
|
// 현재 ± 1 윈도우 허용 (시간 오차 대응)
|
||||||
|
for (int i = -1; i <= 1; i++) {
|
||||||
|
String generated = generateTotpCode(secret, timeStep + i, digits);
|
||||||
|
if (generated.equals(code)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateTotpCode(String base32Secret, long timeStep, int digits) {
|
||||||
|
try {
|
||||||
|
byte[] key = base32Decode(base32Secret);
|
||||||
|
byte[] data = new byte[8];
|
||||||
|
for (int i = 7; i >= 0; i--) {
|
||||||
|
data[i] = (byte) (timeStep & 0xFF);
|
||||||
|
timeStep >>= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1");
|
||||||
|
mac.init(new javax.crypto.spec.SecretKeySpec(key, "HmacSHA1"));
|
||||||
|
byte[] hash = mac.doFinal(data);
|
||||||
|
|
||||||
|
int offset = hash[hash.length - 1] & 0x0F;
|
||||||
|
int binary = ((hash[offset] & 0x7F) << 24)
|
||||||
|
| ((hash[offset + 1] & 0xFF) << 16)
|
||||||
|
| ((hash[offset + 2] & 0xFF) << 8)
|
||||||
|
| (hash[offset + 3] & 0xFF);
|
||||||
|
|
||||||
|
int otp = binary % (int) Math.pow(10, digits);
|
||||||
|
return String.format("%0" + digits + "d", otp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("TOTP generation failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helper Methods =====
|
||||||
|
|
||||||
|
private String generateSecret() {
|
||||||
|
byte[] bytes = new byte[20]; // 160 bits
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return base32Encode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> generateBackupCodes(int count) {
|
||||||
|
return IntStream.range(0, count)
|
||||||
|
.mapToObj(i -> {
|
||||||
|
int code = 10000000 + RANDOM.nextInt(90000000); // 8자리 숫자
|
||||||
|
return String.valueOf(code);
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256 암호화 (JWT secret 앞 32바이트를 키로 사용)
|
||||||
|
*/
|
||||||
|
private String encrypt(String plainText) {
|
||||||
|
try {
|
||||||
|
byte[] key = getAesKey();
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, AES_ALGORITHM));
|
||||||
|
return Base64.getEncoder().encodeToString(cipher.doFinal(plainText.getBytes()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("Encryption failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String decrypt(String cipherText) {
|
||||||
|
try {
|
||||||
|
byte[] key = getAesKey();
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, AES_ALGORITHM));
|
||||||
|
return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("Decryption failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] getAesKey() {
|
||||||
|
String secret = props.getJwt().getSecret();
|
||||||
|
byte[] keyBytes = secret.getBytes();
|
||||||
|
return Arrays.copyOf(keyBytes, 32); // AES-256 = 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base32 encode/decode (RFC 4648)
|
||||||
|
private static final String BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
private String base32Encode(byte[] data) {
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
int buffer = 0, bitsLeft = 0;
|
||||||
|
for (byte b : data) {
|
||||||
|
buffer = (buffer << 8) | (b & 0xFF);
|
||||||
|
bitsLeft += 8;
|
||||||
|
while (bitsLeft >= 5) {
|
||||||
|
result.append(BASE32_CHARS.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
|
||||||
|
bitsLeft -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bitsLeft > 0) {
|
||||||
|
result.append(BASE32_CHARS.charAt((buffer << (5 - bitsLeft)) & 0x1F));
|
||||||
|
}
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] base32Decode(String base32) {
|
||||||
|
String upper = base32.toUpperCase().replaceAll("[^A-Z2-7]", "");
|
||||||
|
byte[] result = new byte[upper.length() * 5 / 8];
|
||||||
|
int buffer = 0, bitsLeft = 0, index = 0;
|
||||||
|
for (char c : upper.toCharArray()) {
|
||||||
|
buffer = (buffer << 5) | BASE32_CHARS.indexOf(c);
|
||||||
|
bitsLeft += 5;
|
||||||
|
if (bitsLeft >= 8) {
|
||||||
|
result[index++] = (byte) (buffer >> (bitsLeft - 8));
|
||||||
|
bitsLeft -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Arrays.copyOf(result, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PasswordPolicy {
|
||||||
|
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
|
||||||
|
public void validate(String password) {
|
||||||
|
var cfg = props.getPassword();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
if (password.length() < cfg.getMinLength()) {
|
||||||
|
errors.add("비밀번호는 " + cfg.getMinLength() + "자 이상이어야 합니다.");
|
||||||
|
}
|
||||||
|
if (cfg.isRequireUppercase() && !password.matches(".*[A-Z].*")) {
|
||||||
|
errors.add("대문자를 포함해야 합니다.");
|
||||||
|
}
|
||||||
|
if (cfg.isRequireDigit() && !password.matches(".*[0-9].*")) {
|
||||||
|
errors.add("숫자를 포함해야 합니다.");
|
||||||
|
}
|
||||||
|
if (cfg.isRequireSpecial() && !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\",./<>?].*")) {
|
||||||
|
errors.add("특수문자를 포함해야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new BusinessException(String.join(" ", errors), "PASSWORD_POLICY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isExpired(WbxUser user) {
|
||||||
|
if (user.getPasswordChangedAt() == null) return true;
|
||||||
|
return user.getPasswordChangedAt()
|
||||||
|
.plusDays(props.getPassword().getExpiryDays())
|
||||||
|
.isBefore(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLocked(WbxUser user) {
|
||||||
|
return user.getFailedLoginAttempts() >= props.getPassword().getMaxFailedAttempts()
|
||||||
|
&& user.isLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RefreshTokenRepository extends JpaRepository<WbxRefreshToken, Long> {
|
||||||
|
|
||||||
|
Optional<WbxRefreshToken> findByTokenHash(String tokenHash);
|
||||||
|
|
||||||
|
@Modifying @Transactional
|
||||||
|
@Query("UPDATE WbxRefreshToken t SET t.isRevoked = true WHERE t.userId = :userId")
|
||||||
|
void revokeAllByUserId(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Modifying @Transactional
|
||||||
|
@Query("DELETE FROM WbxRefreshToken t WHERE t.expiresAt < :now OR t.isRevoked = true")
|
||||||
|
void deleteExpiredOrRevoked(@Param("now") LocalDateTime now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.crypto.codec.Hex;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RefreshTokenService {
|
||||||
|
|
||||||
|
private final RefreshTokenRepository repository;
|
||||||
|
private final WbxUserRepository userRepository;
|
||||||
|
private final JwtProvider jwtProvider;
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Token 생성
|
||||||
|
*/
|
||||||
|
public String create(Long userId, String deviceInfo, String ip) {
|
||||||
|
String rawToken = UUID.randomUUID().toString();
|
||||||
|
String hash = sha256(rawToken);
|
||||||
|
|
||||||
|
repository.save(WbxRefreshToken.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.tokenHash(hash)
|
||||||
|
.deviceInfo(deviceInfo)
|
||||||
|
.ipAddress(ip)
|
||||||
|
.expiresAt(LocalDateTime.now().plusSeconds(props.getJwt().getRefreshExpiration()))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
return rawToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Token으로 Access Token 재발급
|
||||||
|
*/
|
||||||
|
public String refresh(String rawToken) {
|
||||||
|
String hash = sha256(rawToken);
|
||||||
|
WbxRefreshToken stored = repository.findByTokenHash(hash)
|
||||||
|
.filter(t -> !t.isRevoked() && t.getExpiresAt().isAfter(LocalDateTime.now()))
|
||||||
|
.orElseThrow(() -> new BusinessException("Invalid or expired refresh token"));
|
||||||
|
|
||||||
|
WbxUser user = userRepository.findById(stored.getUserId())
|
||||||
|
.orElseThrow(() -> new BusinessException("User not found"));
|
||||||
|
|
||||||
|
return jwtProvider.generateToken(user.toUserDetails());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Token 해지 (로그아웃)
|
||||||
|
*/
|
||||||
|
public void revoke(String rawToken) {
|
||||||
|
String hash = sha256(rawToken);
|
||||||
|
repository.findByTokenHash(hash)
|
||||||
|
.ifPresent(t -> {
|
||||||
|
t.setRevoked(true);
|
||||||
|
repository.save(t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 디바이스 로그아웃
|
||||||
|
*/
|
||||||
|
public void revokeAll(Long userId) {
|
||||||
|
repository.revokeAllByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256(String input) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = md.digest(input.getBytes());
|
||||||
|
return new String(Hex.encode(hash));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("SHA-256 failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
|
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azure Entra ID (OIDC) 로그인 성공 핸들러
|
||||||
|
* <p>
|
||||||
|
* OAuth2 로그인 성공 → WBX 호환 JWT 발급 → 프론트엔드 콜백 URL로 리다이렉트.
|
||||||
|
* <p>
|
||||||
|
* 고객 환경 커스터마이즈:
|
||||||
|
* - wbx.spring.sso.callback-url: 프론트엔드 콜백 (기본: /sso/callback)
|
||||||
|
* - wbx.spring.sso.auto-register: SSO 최초 로그인 시 자동 사용자 등록
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "spring.security.oauth2.client.registration.azure.client-id")
|
||||||
|
public class SsoSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||||
|
|
||||||
|
private final WbxUserRepository userRepository;
|
||||||
|
private final JwtProvider jwtProvider;
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final RefreshTokenService refreshTokenService;
|
||||||
|
private final LoginHistoryRepository loginHistoryRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Authentication authentication) throws IOException {
|
||||||
|
OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
|
||||||
|
|
||||||
|
String email = oidcUser.getEmail();
|
||||||
|
String name = oidcUser.getFullName();
|
||||||
|
String oid = oidcUser.getAttribute("oid"); // Azure Object ID
|
||||||
|
|
||||||
|
log.info("SSO login: email={}, name={}, oid={}", email, name, oid);
|
||||||
|
|
||||||
|
// 사용자 조회 또는 자동 등록
|
||||||
|
WbxUser user = userRepository.findByEmail(email)
|
||||||
|
.orElseGet(() -> autoRegisterSsoUser(email, name, oid));
|
||||||
|
|
||||||
|
// Azure OID 업데이트
|
||||||
|
if (oid != null && !oid.equals(user.getAzureOid())) {
|
||||||
|
user.setAzureOid(oid);
|
||||||
|
user.setSsoProvider("azure");
|
||||||
|
}
|
||||||
|
user.recordLoginSuccess();
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
// JWT 발급
|
||||||
|
String token = jwtProvider.generateToken(user.toUserDetails());
|
||||||
|
String ip = request.getRemoteAddr();
|
||||||
|
String refreshToken = refreshTokenService.create(user.getId(), "SSO", ip);
|
||||||
|
|
||||||
|
// 로그인 이력
|
||||||
|
loginHistoryRepository.save(WbxLoginHistory.builder()
|
||||||
|
.userId(user.getId()).email(email)
|
||||||
|
.action("LOGIN_SUCCESS").authMethod("SSO_AZURE")
|
||||||
|
.ipAddress(ip)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
// 프론트엔드 콜백 리다이렉트 (JWT를 query param으로 전달)
|
||||||
|
String callbackUrl = String.format("/sso/callback?access_token=%s&refresh_token=%s",
|
||||||
|
token, refreshToken);
|
||||||
|
getRedirectStrategy().sendRedirect(request, response, callbackUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WbxUser autoRegisterSsoUser(String email, String fullName, String azureOid) {
|
||||||
|
log.info("Auto-registering SSO user: {}", email);
|
||||||
|
String username = email.split("@")[0];
|
||||||
|
// username 중복 방지
|
||||||
|
if (userRepository.existsByUsername(username)) {
|
||||||
|
username = username + "_" + System.currentTimeMillis() % 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
WbxUser user = WbxUser.builder()
|
||||||
|
.email(email)
|
||||||
|
.username(username)
|
||||||
|
.fullName(fullName != null ? fullName : username)
|
||||||
|
.hashedPassword("") // SSO 사용자는 비밀번호 없음
|
||||||
|
.isActive(true)
|
||||||
|
.isAdmin(false)
|
||||||
|
.ssoProvider("azure")
|
||||||
|
.azureOid(azureOid)
|
||||||
|
.build();
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TotpSecretRepository extends JpaRepository<WbxTotpSecret, Long> {
|
||||||
|
Optional<WbxTotpSecret> findByUserId(Long userId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
void deleteByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_login_history")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxLoginHistory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private String action; // LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT, MFA_SUCCESS, MFA_FAILURE
|
||||||
|
|
||||||
|
@Column(name = "ip_address", length = 50)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "user_agent", length = 500)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
@Column(name = "auth_method", length = 20)
|
||||||
|
private String authMethod; // PASSWORD, SSO, API_KEY, REFRESH_TOKEN
|
||||||
|
|
||||||
|
@Column(name = "failure_reason", length = 200)
|
||||||
|
private String failureReason;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_refresh_tokens")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxRefreshToken {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", nullable = false, unique = true)
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "device_info", length = 500)
|
||||||
|
private String deviceInfo;
|
||||||
|
|
||||||
|
@Column(name = "ip_address", length = 50)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "is_revoked")
|
||||||
|
private boolean isRevoked = false;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import kr.co.accura.wbx.spring.common.BaseEntity;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_totp_secrets")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxTotpSecret extends BaseEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** AES-256 암호화된 TOTP 시크릿 */
|
||||||
|
@Column(nullable = false, length = 500)
|
||||||
|
private String encryptedSecret;
|
||||||
|
|
||||||
|
/** MFA 설정 완료 (QR 스캔 후 검증 통과) */
|
||||||
|
@Builder.Default
|
||||||
|
private boolean verified = false;
|
||||||
|
|
||||||
|
/** JSON 배열 — 해시된 백업 코드 (1회용 복구 코드) */
|
||||||
|
@Column(length = 2000)
|
||||||
|
private String backupCodes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import kr.co.accura.wbx.spring.common.BaseEntity;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_users")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxUser extends BaseEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 100)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
private String hashedPassword;
|
||||||
|
private String fullName;
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Column(name = "department_id")
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String positionTitle;
|
||||||
|
|
||||||
|
@Column(unique = true, length = 50)
|
||||||
|
private String employeeNumber;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private boolean isActive = true;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private boolean isAdmin = false;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private boolean mfaEnabled = false;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String azureOid;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String ssoProvider;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private int failedLoginAttempts = 0;
|
||||||
|
|
||||||
|
private LocalDateTime lastFailedLogin;
|
||||||
|
private LocalDateTime lockedUntil;
|
||||||
|
private LocalDateTime passwordChangedAt;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private boolean mustChangePassword = false;
|
||||||
|
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
|
// 비즈니스 메서드
|
||||||
|
public void recordLoginSuccess() {
|
||||||
|
this.lastLoginAt = LocalDateTime.now();
|
||||||
|
this.failedLoginAttempts = 0;
|
||||||
|
this.lastFailedLogin = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordLoginFailure(int maxAttempts, int lockoutMinutes) {
|
||||||
|
this.failedLoginAttempts++;
|
||||||
|
this.lastFailedLogin = LocalDateTime.now();
|
||||||
|
if (this.failedLoginAttempts >= maxAttempts) {
|
||||||
|
this.lockedUntil = LocalDateTime.now().plusMinutes(lockoutMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLocked() {
|
||||||
|
return lockedUntil != null && lockedUntil.isAfter(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public WbxUserDetails toUserDetails() {
|
||||||
|
return WbxUserDetails.builder()
|
||||||
|
.id(id)
|
||||||
|
.email(email)
|
||||||
|
.username(username)
|
||||||
|
.fullName(fullName != null ? fullName : "")
|
||||||
|
.isAdmin(isAdmin)
|
||||||
|
.departmentId(departmentId)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class WbxUserDetails implements UserDetails {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String email;
|
||||||
|
private String username;
|
||||||
|
private String fullName;
|
||||||
|
private boolean isAdmin;
|
||||||
|
private Long departmentId;
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> roles = new ArrayList<>();
|
||||||
|
@Builder.Default
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||||
|
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||||
|
for (String role : roles) {
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
|
||||||
|
}
|
||||||
|
if (isAdmin) {
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_SA"));
|
||||||
|
}
|
||||||
|
return authorities;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPassword() { return null; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonExpired() { return true; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonLocked() { return true; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCredentialsNonExpired() { return true; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.co.accura.wbx.spring.auth;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WbxUserRepository extends JpaRepository<WbxUser, Long> {
|
||||||
|
|
||||||
|
Optional<WbxUser> findByEmail(String email);
|
||||||
|
|
||||||
|
Optional<WbxUser> findByUsername(String username);
|
||||||
|
|
||||||
|
Optional<WbxUser> findByAzureOid(String azureOid);
|
||||||
|
|
||||||
|
Optional<WbxUser> findByEmployeeNumber(String employeeNumber);
|
||||||
|
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
boolean existsByUsername(String username);
|
||||||
|
|
||||||
|
long countByIsActiveTrue();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package kr.co.accura.wbx.spring.common;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.data.annotation.CreatedBy;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedBy;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@MappedSuperclass
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public abstract class BaseEntity {
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@CreatedBy
|
||||||
|
@Column(updatable = false)
|
||||||
|
private Long createdBy;
|
||||||
|
|
||||||
|
@LastModifiedBy
|
||||||
|
private Long updatedBy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package kr.co.accura.wbx.spring.common;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final HttpStatus status;
|
||||||
|
|
||||||
|
public BusinessException(String message) {
|
||||||
|
this(message, "BUSINESS_ERROR", HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(String message, String code) {
|
||||||
|
this(message, code, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(String message, String code, HttpStatus status) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.co.accura.wbx.spring.common;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
public class NotFoundException extends BusinessException {
|
||||||
|
|
||||||
|
public NotFoundException(String message) {
|
||||||
|
super(message, "NOT_FOUND", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.co.accura.wbx.spring.common;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.auth.WbxUserDetails;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
public final class SecurityUtils {
|
||||||
|
|
||||||
|
private SecurityUtils() {}
|
||||||
|
|
||||||
|
public static Long getCurrentUserId() {
|
||||||
|
var user = getCurrentUser();
|
||||||
|
return user != null ? user.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getCurrentUsername() {
|
||||||
|
var user = getCurrentUser();
|
||||||
|
return user != null ? user.getUsername() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long getCurrentDeptId() {
|
||||||
|
var user = getCurrentUser();
|
||||||
|
return user != null ? user.getDepartmentId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WbxUserDetails getCurrentUser() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.getPrincipal() instanceof WbxUserDetails details) {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package kr.co.accura.wbx.spring.compat;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.common.NotFoundException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WBX FastAPI 호환 에러 핸들러
|
||||||
|
* 에러 응답에 "detail" 키 사용 (FastAPI 형식)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class WbxErrorHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleBusiness(BusinessException ex) {
|
||||||
|
return ResponseEntity.status(ex.getStatus()).body(Map.of(
|
||||||
|
"detail", ex.getMessage(),
|
||||||
|
"code", ex.getCode(),
|
||||||
|
"timestamp", LocalDateTime.now().toString()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NotFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleNotFound(NotFoundException ex) {
|
||||||
|
return ResponseEntity.status(404).body(Map.of(
|
||||||
|
"detail", ex.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleForbidden(AccessDeniedException ex) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of(
|
||||||
|
"detail", "Access denied"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
String detail = ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"detail", detail
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
|
||||||
|
log.error("Unhandled exception: {}", ex.getMessage(), ex);
|
||||||
|
return ResponseEntity.status(500).body(Map.of(
|
||||||
|
"detail", "Internal server error"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package kr.co.accura.wbx.spring.compat;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.MethodParameter;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||||
|
import org.springframework.web.context.request.NativeWebRequest;
|
||||||
|
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||||
|
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WBX No-Code 프론트엔드 호환 — skip/limit → Spring Pageable 변환
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class WbxPaginationConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
|
||||||
|
resolvers.add(new WbxPaginationResolver());
|
||||||
|
}
|
||||||
|
|
||||||
|
static class WbxPaginationResolver implements HandlerMethodArgumentResolver {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsParameter(MethodParameter parameter) {
|
||||||
|
return Pageable.class.isAssignableFrom(parameter.getParameterType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Pageable resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
|
||||||
|
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
|
||||||
|
String skipStr = webRequest.getParameter("skip");
|
||||||
|
String limitStr = webRequest.getParameter("limit");
|
||||||
|
String sortStr = webRequest.getParameter("sort");
|
||||||
|
|
||||||
|
int skip = skipStr != null ? Integer.parseInt(skipStr) : 0;
|
||||||
|
int limit = limitStr != null ? Integer.parseInt(limitStr) : 20;
|
||||||
|
int page = skip / Math.max(limit, 1);
|
||||||
|
|
||||||
|
Sort sort = sortStr != null && !sortStr.isBlank()
|
||||||
|
? Sort.by(sortStr.split(","))
|
||||||
|
: Sort.unsorted();
|
||||||
|
|
||||||
|
return PageRequest.of(page, limit, sort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package kr.co.accura.wbx.spring.config;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CorsAutoConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
String[] origins = props.getCors().getAllowedOrigins().toArray(new String[0]);
|
||||||
|
registry.addMapping("/api/**")
|
||||||
|
.allowedOriginPatterns(origins)
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.exposedHeaders("Content-Disposition", "X-Total-Count")
|
||||||
|
.allowCredentials(true)
|
||||||
|
.maxAge(3600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.co.accura.wbx.spring.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI customOpenAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("WBX Spring Framework API")
|
||||||
|
.version("1.0.0")
|
||||||
|
.description("WBX Spring Boot 통합 프레임워크 REST API"))
|
||||||
|
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
|
||||||
|
.components(new Components()
|
||||||
|
.addSecuritySchemes("Bearer",
|
||||||
|
new SecurityScheme()
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.scheme("bearer")
|
||||||
|
.bearerFormat("JWT")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package kr.co.accura.wbx.spring.config;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.auth.JwtFilter;
|
||||||
|
import kr.co.accura.wbx.spring.auth.SsoSuccessHandler;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityAutoConfig {
|
||||||
|
|
||||||
|
private final JwtFilter jwtFilter;
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
|
||||||
|
/** SSO 핸들러 — Azure OAuth2 설정이 있을 때만 주입 */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private SsoSuccessHandler ssoSuccessHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin Console — 세션 기반 폼 로그인
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(1)
|
||||||
|
public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
return http
|
||||||
|
.securityMatcher("/admin/**", "/admin")
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/admin/css/**", "/admin/js/**").permitAll()
|
||||||
|
.requestMatchers("/admin/login").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.formLogin(form -> form
|
||||||
|
.loginPage("/admin/login")
|
||||||
|
.defaultSuccessUrl("/admin", true)
|
||||||
|
.permitAll()
|
||||||
|
)
|
||||||
|
.logout(logout -> logout
|
||||||
|
.logoutUrl("/admin/logout")
|
||||||
|
.logoutSuccessUrl("/admin/login?logout")
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API — JWT Stateless
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(2)
|
||||||
|
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
String prefix = props.getApiPrefix();
|
||||||
|
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(prefix + "/auth/**").permitAll()
|
||||||
|
.requestMatchers("/health", "/actuator/**").permitAll()
|
||||||
|
.requestMatchers("/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.requestMatchers("/sso/**").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
// Azure Entra SSO — client-id 설정이 있으면 OAuth2Login 활성화
|
||||||
|
if (ssoSuccessHandler != null) {
|
||||||
|
http.oauth2Login(oauth2 -> oauth2
|
||||||
|
.successHandler(ssoSuccessHandler)
|
||||||
|
.failureUrl("/sso/error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package kr.co.accura.wbx.spring.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "wbx.spring")
|
||||||
|
public class WbxSpringProperties {
|
||||||
|
|
||||||
|
private String apiPrefix = "/api";
|
||||||
|
private String apiVersion = "v1";
|
||||||
|
|
||||||
|
private Jwt jwt = new Jwt();
|
||||||
|
private Mfa mfa = new Mfa();
|
||||||
|
private Password password = new Password();
|
||||||
|
private Cors cors = new Cors();
|
||||||
|
private FileConfig file = new FileConfig();
|
||||||
|
private Approval approval = new Approval();
|
||||||
|
private Notification notification = new Notification();
|
||||||
|
private DataSourceConfig datasource = new DataSourceConfig();
|
||||||
|
private AdminUi adminUi = new AdminUi();
|
||||||
|
private Compat compat = new Compat();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Jwt {
|
||||||
|
private String secret = "wbx-spring-default-secret-key-change-in-production-minimum-256-bits";
|
||||||
|
private long expiration = 28800; // 8 hours
|
||||||
|
private long refreshExpiration = 604800; // 7 days
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Mfa {
|
||||||
|
private boolean enabled = false;
|
||||||
|
private boolean forceForExternal = true;
|
||||||
|
private boolean forceForInternal = false;
|
||||||
|
private String totpIssuer = "WBX Platform";
|
||||||
|
private int totpDigits = 6;
|
||||||
|
private int totpPeriod = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Password {
|
||||||
|
private int minLength = 8;
|
||||||
|
private int maxFailedAttempts = 5;
|
||||||
|
private int expiryDays = 90;
|
||||||
|
private int historyCount = 3;
|
||||||
|
private boolean requireUppercase = true;
|
||||||
|
private boolean requireDigit = true;
|
||||||
|
private boolean requireSpecial = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Cors {
|
||||||
|
private List<String> allowedOrigins = new ArrayList<>(List.of("http://localhost:5173", "http://localhost:3000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class FileConfig {
|
||||||
|
private String storageType = "local"; // local, azure-blob, aws-s3, gcp-storage
|
||||||
|
private String uploadDir = "./uploads";
|
||||||
|
private long maxSizeMb = 50;
|
||||||
|
private AzureConfig azure = new AzureConfig();
|
||||||
|
private AwsConfig aws = new AwsConfig();
|
||||||
|
private GcpConfig gcp = new GcpConfig();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class AzureConfig {
|
||||||
|
private String accountName = "";
|
||||||
|
private String accountKey = "";
|
||||||
|
private String containerName = "uploads";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class AwsConfig {
|
||||||
|
private String bucket = "";
|
||||||
|
private String region = "ap-northeast-2";
|
||||||
|
private String accessKey = "";
|
||||||
|
private String secretKey = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class GcpConfig {
|
||||||
|
private String bucket = "";
|
||||||
|
private String projectId = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Notification {
|
||||||
|
private boolean sseEnabled = true;
|
||||||
|
private boolean websocketEnabled = false;
|
||||||
|
private int heartbeatSeconds = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class AdminUi {
|
||||||
|
private boolean enabled = false;
|
||||||
|
private String path = "/admin";
|
||||||
|
private List<String> allowRoles = new ArrayList<>(List.of("SA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Approval {
|
||||||
|
private boolean enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class DataSourceConfig {
|
||||||
|
private boolean routingEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Compat {
|
||||||
|
private String errorFormat = "fastapi"; // detail key
|
||||||
|
private String listKey = "items";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.co.accura.wbx.spring.config;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_system_config")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxSystemConfig {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "config_key", nullable = false, unique = true, length = 100)
|
||||||
|
private String configKey;
|
||||||
|
|
||||||
|
@Column(name = "config_value", length = 4000)
|
||||||
|
private String configValue;
|
||||||
|
|
||||||
|
@Column(name = "value_type", length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String valueType = "STRING"; // STRING, INT, BOOLEAN, JSON
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "is_editable")
|
||||||
|
private boolean isEditable = true;
|
||||||
|
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private Long updatedBy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.co.accura.wbx.spring.datasource;
|
||||||
|
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터소스 전환 어노테이션
|
||||||
|
* <p>
|
||||||
|
* 메서드 또는 클래스에 사용하여 특정 데이터소스를 지정합니다.
|
||||||
|
* <pre>
|
||||||
|
* {@literal @}DataSource("readonly")
|
||||||
|
* public List<User> findAll() { ... }
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @see WbxRoutingDataSource
|
||||||
|
* @see DataSourceAspect
|
||||||
|
*/
|
||||||
|
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
public @interface DataSource {
|
||||||
|
/** 데이터소스 이름: "app" (기본), "wbxgw", "readonly" */
|
||||||
|
String value() default "app";
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package kr.co.accura.wbx.spring.datasource;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @DataSource 어노테이션 AOP
|
||||||
|
* <p>
|
||||||
|
* wbx.spring.datasource.routing-enabled=true 일 때 활성화.
|
||||||
|
* 메서드 → 클래스 순서로 @DataSource 어노테이션을 탐색합니다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
@Order(-1) // 트랜잭션 AOP보다 먼저 실행
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.datasource.routing-enabled", havingValue = "true")
|
||||||
|
public class DataSourceAspect {
|
||||||
|
|
||||||
|
@Around("@annotation(kr.co.accura.wbx.spring.datasource.DataSource) || " +
|
||||||
|
"@within(kr.co.accura.wbx.spring.datasource.DataSource)")
|
||||||
|
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||||
|
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||||
|
Method method = signature.getMethod();
|
||||||
|
|
||||||
|
// 메서드 레벨 우선
|
||||||
|
DataSource ds = method.getAnnotation(DataSource.class);
|
||||||
|
if (ds == null) {
|
||||||
|
// 클래스 레벨
|
||||||
|
ds = point.getTarget().getClass().getAnnotation(DataSource.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
String key = (ds != null) ? ds.value() : "app";
|
||||||
|
String previous = WbxRoutingDataSource.getDataSourceKey();
|
||||||
|
|
||||||
|
try {
|
||||||
|
WbxRoutingDataSource.setDataSourceKey(key);
|
||||||
|
log.debug("DataSource switched to: {}", key);
|
||||||
|
return point.proceed();
|
||||||
|
} finally {
|
||||||
|
if (previous != null) {
|
||||||
|
WbxRoutingDataSource.setDataSourceKey(previous);
|
||||||
|
} else {
|
||||||
|
WbxRoutingDataSource.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package kr.co.accura.wbx.spring.datasource;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-DataSource 설정 (선택적 활성화)
|
||||||
|
* <p>
|
||||||
|
* wbx.spring.datasource.routing-enabled=true 일 때 활성화.
|
||||||
|
* 기본: 단일 DataSource (Spring Boot 자동 설정).
|
||||||
|
* <p>
|
||||||
|
* 사용법 (application.yml):
|
||||||
|
* <pre>
|
||||||
|
* wbx.spring.datasource.routing-enabled: true
|
||||||
|
*
|
||||||
|
* spring.datasource: # 기본 (app)
|
||||||
|
* url: jdbc:mysql://...
|
||||||
|
*
|
||||||
|
* wbx.spring.datasource.wbxgw: # WBX Groupware DB (선택)
|
||||||
|
* url: jdbc:mysql://...
|
||||||
|
* username: xxx
|
||||||
|
* password: xxx
|
||||||
|
*
|
||||||
|
* wbx.spring.datasource.readonly: # 읽기전용 (선택)
|
||||||
|
* url: jdbc:mysql://...
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.datasource.routing-enabled", havingValue = "true")
|
||||||
|
public class MultiDataSourceConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConfigurationProperties("spring.datasource")
|
||||||
|
public DataSourceProperties appDataSourceProperties() {
|
||||||
|
return new DataSourceProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DataSource appDataSource() {
|
||||||
|
return appDataSourceProperties().initializeDataSourceBuilder()
|
||||||
|
.type(HikariDataSource.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConfigurationProperties("wbx.spring.datasource.wbxgw")
|
||||||
|
public DataSourceProperties wbxgwDataSourceProperties() {
|
||||||
|
return new DataSourceProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DataSource wbxgwDataSource() {
|
||||||
|
DataSourceProperties props = wbxgwDataSourceProperties();
|
||||||
|
if (props.getUrl() == null || props.getUrl().isBlank()) {
|
||||||
|
log.info("wbxgw datasource not configured, skipping");
|
||||||
|
return appDataSource(); // fallback to app
|
||||||
|
}
|
||||||
|
return props.initializeDataSourceBuilder()
|
||||||
|
.type(HikariDataSource.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public DataSource routingDataSource(
|
||||||
|
@Qualifier("appDataSource") DataSource appDs,
|
||||||
|
@Qualifier("wbxgwDataSource") DataSource wbxgwDs) {
|
||||||
|
|
||||||
|
WbxRoutingDataSource routing = new WbxRoutingDataSource();
|
||||||
|
|
||||||
|
Map<Object, Object> targets = new HashMap<>();
|
||||||
|
targets.put("app", appDs);
|
||||||
|
targets.put("wbxgw", wbxgwDs);
|
||||||
|
|
||||||
|
routing.setTargetDataSources(targets);
|
||||||
|
routing.setDefaultTargetDataSource(appDs);
|
||||||
|
routing.afterPropertiesSet();
|
||||||
|
|
||||||
|
log.info("Multi-DataSource routing enabled: {}", targets.keySet());
|
||||||
|
return routing;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package kr.co.accura.wbx.spring.datasource;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThreadLocal 기반 동적 데이터소스 라우팅
|
||||||
|
* <p>
|
||||||
|
* DataSourceAspect가 @DataSource 어노테이션을 감지하여
|
||||||
|
* ThreadLocal에 데이터소스 키를 설정하면, 이 클래스가 해당 키를 반환합니다.
|
||||||
|
*/
|
||||||
|
public class WbxRoutingDataSource extends AbstractRoutingDataSource {
|
||||||
|
|
||||||
|
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
public static void setDataSourceKey(String key) {
|
||||||
|
CONTEXT.set(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getDataSourceKey() {
|
||||||
|
return CONTEXT.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CONTEXT.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Object determineCurrentLookupKey() {
|
||||||
|
return CONTEXT.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package kr.co.accura.wbx.spring.file;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AWS S3 스토리지 구현 (표준 템플릿)
|
||||||
|
* <p>
|
||||||
|
* ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다:
|
||||||
|
* <pre>
|
||||||
|
* // build.gradle
|
||||||
|
* implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0'
|
||||||
|
*
|
||||||
|
* // 교체 후: S3Client.putObject() / getObject() / deleteObject() 사용
|
||||||
|
* </pre>
|
||||||
|
* <p>
|
||||||
|
* 현재 구현은 Pre-signed URL 방식을 사용하며,
|
||||||
|
* AWS CLI 인증(~/.aws/credentials)이 설정된 환경에서 동작합니다.
|
||||||
|
* <p>
|
||||||
|
* 설정:
|
||||||
|
* <pre>
|
||||||
|
* wbx.spring.file.storage-type: aws-s3
|
||||||
|
* wbx.spring.file.aws.bucket: ${AWS_S3_BUCKET}
|
||||||
|
* wbx.spring.file.aws.region: ap-northeast-2
|
||||||
|
* wbx.spring.file.aws.access-key: ${AWS_ACCESS_KEY}
|
||||||
|
* wbx.spring.file.aws.secret-key: ${AWS_SECRET_KEY}
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "aws-s3")
|
||||||
|
public class AwsS3StorageService implements FileStorageService {
|
||||||
|
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
public AwsS3StorageService(WbxSpringProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String upload(MultipartFile file, String category) {
|
||||||
|
try {
|
||||||
|
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||||
|
String objectKey = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
|
||||||
|
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
|
||||||
|
aws.getBucket(), aws.getRegion(), objectKey);
|
||||||
|
|
||||||
|
byte[] content = file.getBytes();
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Content-Type", file.getContentType())
|
||||||
|
.header("x-amz-date", amzDate(now))
|
||||||
|
.header("x-amz-content-sha256", sha256Hex(content))
|
||||||
|
.PUT(HttpRequest.BodyPublishers.ofByteArray(content))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() >= 300) {
|
||||||
|
throw new BusinessException("S3 upload failed: " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("S3 uploaded: {}/{}", aws.getBucket(), objectKey);
|
||||||
|
return objectKey;
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("S3 upload failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] download(String fileKey) {
|
||||||
|
try {
|
||||||
|
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||||
|
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
|
||||||
|
aws.getBucket(), aws.getRegion(), fileKey);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("x-amz-date", amzDate(ZonedDateTime.now(ZoneOffset.UTC)))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (response.statusCode() == 404) {
|
||||||
|
throw new BusinessException("File not found: " + fileKey);
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("S3 download failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String fileKey) {
|
||||||
|
try {
|
||||||
|
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||||
|
String url = String.format("https://%s.s3.%s.amazonaws.com/%s",
|
||||||
|
aws.getBucket(), aws.getRegion(), fileKey);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.DELETE()
|
||||||
|
.build();
|
||||||
|
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
log.info("S3 deleted: {}", fileKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("S3 delete failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||||
|
WbxSpringProperties.FileConfig.AwsConfig aws = props.getFile().getAws();
|
||||||
|
// Pre-signed URL (간략 구현 — 프로덕션은 AWS SDK 권장)
|
||||||
|
return String.format("https://%s.s3.%s.amazonaws.com/%s?X-Amz-Expires=%d",
|
||||||
|
aws.getBucket(), aws.getRegion(), fileKey, expirySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helper =====
|
||||||
|
|
||||||
|
private String amzDate(ZonedDateTime dt) {
|
||||||
|
return DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").format(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256Hex(byte[] data) {
|
||||||
|
try {
|
||||||
|
byte[] hash = MessageDigest.getInstance("SHA-256").digest(data);
|
||||||
|
return HexFormat.of().formatHex(hash);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "UNSIGNED-PAYLOAD";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String filename) {
|
||||||
|
if (filename == null) return "";
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package kr.co.accura.wbx.spring.file;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.*;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azure Blob Storage 구현
|
||||||
|
* <p>
|
||||||
|
* Azure SDK 없이 REST API 직접 호출 (의존성 최소화).
|
||||||
|
* 고객사에 Azure SDK 사용 가능하면 spring-cloud-azure-starter-storage-blob으로 교체 권장.
|
||||||
|
* <p>
|
||||||
|
* 설정:
|
||||||
|
* <pre>
|
||||||
|
* wbx.spring.file.storage-type: azure-blob
|
||||||
|
* wbx.spring.file.azure.account-name: ${AZURE_STORAGE_ACCOUNT}
|
||||||
|
* wbx.spring.file.azure.account-key: ${AZURE_STORAGE_KEY}
|
||||||
|
* wbx.spring.file.azure.container-name: uploads
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "azure-blob")
|
||||||
|
public class AzureBlobStorageService implements FileStorageService {
|
||||||
|
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
public AzureBlobStorageService(WbxSpringProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String upload(MultipartFile file, String category) {
|
||||||
|
try {
|
||||||
|
String blobName = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
|
||||||
|
// SAS Token 방식 — SharedKey 서명 없이 URL에 인증 포함
|
||||||
|
String sasUrl = generateSasUrl(blobName, "rcw", 3600); // read+create+write, 1시간
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(sasUrl))
|
||||||
|
.header("x-ms-blob-type", "BlockBlob")
|
||||||
|
.header("Content-Type", file.getContentType() != null ? file.getContentType() : "application/octet-stream")
|
||||||
|
.header("x-ms-version", "2023-11-03")
|
||||||
|
.PUT(HttpRequest.BodyPublishers.ofByteArray(file.getBytes()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() >= 300) {
|
||||||
|
throw new BusinessException("Azure Blob upload failed: HTTP " + response.statusCode() + " " + response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Azure Blob uploaded: {}", blobName);
|
||||||
|
return blobName;
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("Azure Blob upload failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] download(String fileKey) {
|
||||||
|
try {
|
||||||
|
String sasUrl = generateSasUrl(fileKey, "r", 3600); // read, 1시간
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(sasUrl))
|
||||||
|
.header("x-ms-version", "2023-11-03")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (response.statusCode() == 404) {
|
||||||
|
throw new BusinessException("File not found: " + fileKey);
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("Azure Blob download failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String fileKey) {
|
||||||
|
try {
|
||||||
|
String sasUrl = generateSasUrl(fileKey, "d", 3600); // delete, 1시간
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(sasUrl))
|
||||||
|
.header("x-ms-version", "2023-11-03")
|
||||||
|
.DELETE()
|
||||||
|
.build();
|
||||||
|
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
log.info("Azure Blob deleted: {}", fileKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Azure Blob delete failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||||
|
return generateSasUrl(fileKey, "r", expirySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azure SAS Token URL 생성 (Service SAS)
|
||||||
|
* @param blobName Blob 경로
|
||||||
|
* @param permissions 권한: r=read, w=write, d=delete, c=create 조합
|
||||||
|
* @param expirySeconds 유효기간 (초)
|
||||||
|
*/
|
||||||
|
private String generateSasUrl(String blobName, String permissions, long expirySeconds) {
|
||||||
|
WbxSpringProperties.FileConfig.AzureConfig azure = props.getFile().getAzure();
|
||||||
|
String accountName = azure.getAccountName();
|
||||||
|
String containerName = azure.getContainerName();
|
||||||
|
|
||||||
|
OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(5); // 시간 오차 대비
|
||||||
|
OffsetDateTime expiry = OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(expirySeconds);
|
||||||
|
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
|
||||||
|
String stringToSign = String.join("\n",
|
||||||
|
permissions,
|
||||||
|
fmt.format(start),
|
||||||
|
fmt.format(expiry),
|
||||||
|
String.format("/blob/%s/%s/%s", accountName, containerName, blobName),
|
||||||
|
"", // identifier
|
||||||
|
"", // IP
|
||||||
|
"https", // protocol
|
||||||
|
"2023-11-03", // version
|
||||||
|
"b", // resource (blob)
|
||||||
|
"", "", "", "" // snapshot, encryption, cache, content
|
||||||
|
);
|
||||||
|
|
||||||
|
String signature = hmacSha256(azure.getAccountKey(), stringToSign);
|
||||||
|
return String.format("%s?sp=%s&st=%s&se=%s&spr=https&sv=2023-11-03&sr=b&sig=%s",
|
||||||
|
getBlobUrl(blobName), permissions, fmt.format(start), fmt.format(expiry),
|
||||||
|
java.net.URLEncoder.encode(signature, java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helper =====
|
||||||
|
|
||||||
|
private String getBlobUrl(String blobName) {
|
||||||
|
WbxSpringProperties.FileConfig.AzureConfig azure = props.getFile().getAzure();
|
||||||
|
return String.format("https://%s.blob.core.windows.net/%s/%s",
|
||||||
|
azure.getAccountName(), azure.getContainerName(), blobName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String gmtDate() {
|
||||||
|
return DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hmacSha256(String key, String data) {
|
||||||
|
try {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(key);
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(keyBytes, "HmacSHA256"));
|
||||||
|
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("HMAC signing failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String filename) {
|
||||||
|
if (filename == null) return "";
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.accura.wbx.spring.file;
|
||||||
|
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
public interface FileStorageService {
|
||||||
|
String upload(MultipartFile file, String category);
|
||||||
|
byte[] download(String fileKey);
|
||||||
|
void delete(String fileKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임시 다운로드 URL 생성 (클라우드 스토리지용).
|
||||||
|
* 로컬 구현은 빈 문자열 반환.
|
||||||
|
*/
|
||||||
|
default String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package kr.co.accura.wbx.spring.file;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Cloud Storage 구현 (표준 템플릿)
|
||||||
|
* <p>
|
||||||
|
* ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다:
|
||||||
|
* <pre>
|
||||||
|
* // build.gradle
|
||||||
|
* implementation 'com.google.cloud:spring-cloud-gcp-starter-storage:5.9.0'
|
||||||
|
*
|
||||||
|
* // 교체 후: Storage.create() / readAllBytes() / delete() 사용
|
||||||
|
* </pre>
|
||||||
|
* <p>
|
||||||
|
* 현재 구현은 GOOGLE_APPLICATION_CREDENTIALS 환경변수(서비스 계정 JSON)로
|
||||||
|
* 인증된 환경에서 GCP JSON API를 직접 호출합니다.
|
||||||
|
* <p>
|
||||||
|
* 설정:
|
||||||
|
* <pre>
|
||||||
|
* wbx.spring.file.storage-type: gcp-storage
|
||||||
|
* wbx.spring.file.gcp.bucket: ${GCP_STORAGE_BUCKET}
|
||||||
|
* wbx.spring.file.gcp.project-id: ${GCP_PROJECT_ID}
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "gcp-storage")
|
||||||
|
public class GcpStorageService implements FileStorageService {
|
||||||
|
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
public GcpStorageService(WbxSpringProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
this.httpClient = HttpClient.newHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String upload(MultipartFile file, String category) {
|
||||||
|
try {
|
||||||
|
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||||
|
String objectName = category + "/" + UUID.randomUUID() + getExtension(file.getOriginalFilename());
|
||||||
|
|
||||||
|
String url = String.format(
|
||||||
|
"https://storage.googleapis.com/upload/storage/v1/b/%s/o?uploadType=media&name=%s",
|
||||||
|
gcp.getBucket(), objectName);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Content-Type", file.getContentType())
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(file.getBytes()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() >= 300) {
|
||||||
|
throw new BusinessException("GCS upload failed: " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("GCS uploaded: gs://{}/{}", gcp.getBucket(), objectName);
|
||||||
|
return objectName;
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("GCS upload failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] download(String fileKey) {
|
||||||
|
try {
|
||||||
|
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||||
|
String url = String.format("https://storage.googleapis.com/storage/v1/b/%s/o/%s?alt=media",
|
||||||
|
gcp.getBucket(), fileKey);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (response.statusCode() == 404) {
|
||||||
|
throw new BusinessException("File not found: " + fileKey);
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("GCS download failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String fileKey) {
|
||||||
|
try {
|
||||||
|
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||||
|
String url = String.format("https://storage.googleapis.com/storage/v1/b/%s/o/%s",
|
||||||
|
gcp.getBucket(), fileKey);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.DELETE()
|
||||||
|
.build();
|
||||||
|
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
log.info("GCS deleted: {}", fileKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("GCS delete failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPresignedUrl(String fileKey, long expirySeconds) {
|
||||||
|
WbxSpringProperties.FileConfig.GcpConfig gcp = props.getFile().getGcp();
|
||||||
|
// Signed URL (간략 구현 — 프로덕션은 GCP SDK 권장)
|
||||||
|
return String.format("https://storage.googleapis.com/%s/%s", gcp.getBucket(), fileKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String filename) {
|
||||||
|
if (filename == null) return "";
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? "." + filename.substring(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package kr.co.accura.wbx.spring.file;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.BusinessException;
|
||||||
|
import kr.co.accura.wbx.spring.common.NotFoundException;
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "wbx.spring.file.storage-type", havingValue = "local", matchIfMissing = true)
|
||||||
|
public class LocalFileStorageService implements FileStorageService {
|
||||||
|
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String upload(MultipartFile file, String category) {
|
||||||
|
try {
|
||||||
|
Path dir = Paths.get(props.getFile().getUploadDir(), category);
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
|
||||||
|
String ext = getExtension(file.getOriginalFilename());
|
||||||
|
String storedName = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
|
||||||
|
Path target = dir.resolve(storedName);
|
||||||
|
|
||||||
|
file.transferTo(target.toFile());
|
||||||
|
log.info("File uploaded: {} → {}", file.getOriginalFilename(), target);
|
||||||
|
|
||||||
|
return category + "/" + storedName;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new BusinessException("File upload failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] download(String fileKey) {
|
||||||
|
try {
|
||||||
|
Path path = Paths.get(props.getFile().getUploadDir(), fileKey);
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
throw new NotFoundException("File not found: " + fileKey);
|
||||||
|
}
|
||||||
|
return Files.readAllBytes(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new BusinessException("File download failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String fileKey) {
|
||||||
|
try {
|
||||||
|
Path path = Paths.get(props.getFile().getUploadDir(), fileKey);
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("File delete failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtension(String filename) {
|
||||||
|
if (filename == null) return "";
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? filename.substring(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kr.co.accura.wbx.spring.file;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_file_uploads")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxFileUpload {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "original_name", nullable = false, length = 500)
|
||||||
|
private String originalName;
|
||||||
|
|
||||||
|
@Column(name = "stored_name", nullable = false, length = 500)
|
||||||
|
private String storedName;
|
||||||
|
|
||||||
|
@Column(name = "storage_type", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String storageType = "LOCAL";
|
||||||
|
|
||||||
|
@Column(name = "storage_path", nullable = false, length = 1000)
|
||||||
|
private String storagePath;
|
||||||
|
|
||||||
|
@Column(name = "content_type", length = 200)
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
@Column(name = "file_size")
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
@Column(length = 50)
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "is_deleted")
|
||||||
|
private boolean isDeleted = false;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.accura.wbx.spring.notification;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_notifications")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class Notification {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(length = 2000)
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String link;
|
||||||
|
|
||||||
|
@Column(name = "source_type", length = 50)
|
||||||
|
private String sourceType;
|
||||||
|
|
||||||
|
@Column(name = "source_id")
|
||||||
|
private Long sourceId;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "is_read")
|
||||||
|
private boolean isRead = false;
|
||||||
|
|
||||||
|
@Column(name = "read_at")
|
||||||
|
private LocalDateTime readAt;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package kr.co.accura.wbx.spring.notification;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.auth.WbxUserDetails;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("${wbx.spring.api-prefix:/api}/notifications")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotificationController {
|
||||||
|
|
||||||
|
private final SseNotificationService sseService;
|
||||||
|
|
||||||
|
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter stream(@AuthenticationPrincipal WbxUserDetails user) {
|
||||||
|
return sseService.connect(user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/unread-count")
|
||||||
|
public Map<String, Object> unreadCount(@AuthenticationPrincipal WbxUserDetails user) {
|
||||||
|
return Map.of("count", 0); // TODO: DB 연동 후 구현
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.co.accura.wbx.spring.notification;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record NotificationDto(
|
||||||
|
String type,
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
String link
|
||||||
|
) {
|
||||||
|
public static NotificationDto approvalRequest(String title, String message) {
|
||||||
|
return NotificationDto.builder().type("APPROVAL_REQUEST").title(title).message(message).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotificationDto approvalComplete(String title, String message) {
|
||||||
|
return NotificationDto.builder().type("APPROVAL_COMPLETE").title(title).message(message).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotificationDto reminder(String message) {
|
||||||
|
return NotificationDto.builder().type("REMINDER").title("리마인더").message(message).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package kr.co.accura.wbx.spring.notification;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.config.WbxSpringProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SseNotificationService {
|
||||||
|
|
||||||
|
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||||
|
private final WbxSpringProperties props;
|
||||||
|
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE 연결 생성 (브라우저 EventSource)
|
||||||
|
*/
|
||||||
|
public SseEmitter connect(Long userId) {
|
||||||
|
SseEmitter emitter = new SseEmitter(3600_000L); // 1 hour
|
||||||
|
emitters.put(userId, emitter);
|
||||||
|
|
||||||
|
emitter.onCompletion(() -> emitters.remove(userId));
|
||||||
|
emitter.onTimeout(() -> emitters.remove(userId));
|
||||||
|
emitter.onError(e -> emitters.remove(userId));
|
||||||
|
|
||||||
|
// Heartbeat
|
||||||
|
int heartbeat = props.getNotification().getHeartbeatSeconds();
|
||||||
|
ScheduledFuture<?> heartbeatTask = scheduler.scheduleAtFixedRate(() -> {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("heartbeat").data(""));
|
||||||
|
} catch (Exception e) {
|
||||||
|
emitters.remove(userId);
|
||||||
|
}
|
||||||
|
}, heartbeat, heartbeat, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
emitter.onCompletion(() -> heartbeatTask.cancel(true));
|
||||||
|
emitter.onTimeout(() -> heartbeatTask.cancel(true));
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 사용자에게 알림 전송
|
||||||
|
*/
|
||||||
|
public void sendToUser(Long userId, NotificationDto notification) {
|
||||||
|
SseEmitter emitter = emitters.get(userId);
|
||||||
|
if (emitter != null) {
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("notification")
|
||||||
|
.data(notification));
|
||||||
|
} catch (Exception e) {
|
||||||
|
emitters.remove(userId);
|
||||||
|
log.debug("SSE send failed for user {}: {}", userId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getActiveConnections() {
|
||||||
|
return emitters.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.co.accura.wbx.spring.rbac;
|
||||||
|
|
||||||
|
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||||
|
import jakarta.persistence.criteria.Predicate;
|
||||||
|
import jakarta.persistence.criteria.Root;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WBX 데이터 접근 범위
|
||||||
|
* OWN = 본인 데이터만
|
||||||
|
* DEPT = 소속 부서 데이터
|
||||||
|
* COMPANY = 전사 데이터
|
||||||
|
*/
|
||||||
|
public enum DeptScope {
|
||||||
|
OWN,
|
||||||
|
DEPT,
|
||||||
|
COMPANY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA Specification 자동 생성
|
||||||
|
*/
|
||||||
|
public <T> Specification<T> toSpec(Long userId, Long deptId, String userField, String deptField) {
|
||||||
|
return switch (this) {
|
||||||
|
case OWN -> (root, query, cb) -> cb.equal(root.get(userField), userId);
|
||||||
|
case DEPT -> (root, query, cb) -> cb.equal(root.get(deptField), deptId);
|
||||||
|
case COMPANY -> (root, query, cb) -> cb.conjunction(); // no filter
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package kr.co.accura.wbx.spring.rbac;
|
||||||
|
|
||||||
|
import kr.co.accura.wbx.spring.common.SecurityUtils;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WBX RBAC 권한 체크
|
||||||
|
* Controller에서: @PreAuthorize("@wbx.check('TIMESHEET', 'VIEW')")
|
||||||
|
*/
|
||||||
|
@Component("wbx")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PermissionEvaluator {
|
||||||
|
|
||||||
|
private final RolePermissionRepository permRepo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모듈-액션 기반 권한 체크
|
||||||
|
*/
|
||||||
|
@Cacheable(value = "permissions", key = "#module + ':' + #action + ':' + T(kr.co.accura.wbx.spring.common.SecurityUtils).getCurrentUserId()")
|
||||||
|
public boolean check(String module, String action) {
|
||||||
|
Long userId = SecurityUtils.getCurrentUserId();
|
||||||
|
if (userId == null) return false;
|
||||||
|
return permRepo.existsByUserIdAndModuleAndAction(userId, module, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dept_scope 반환: 데이터 필터링 범위 결정
|
||||||
|
*/
|
||||||
|
@Cacheable(value = "deptScopes", key = "#module + ':' + #action + ':' + T(kr.co.accura.wbx.spring.common.SecurityUtils).getCurrentUserId()")
|
||||||
|
public DeptScope getScope(String module, String action) {
|
||||||
|
Long userId = SecurityUtils.getCurrentUserId();
|
||||||
|
if (userId == null) return DeptScope.OWN;
|
||||||
|
return permRepo.findMaxDeptScope(userId, module, action)
|
||||||
|
.orElse(DeptScope.OWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.co.accura.wbx.spring.rbac;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_role_permissions",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"role_id", "module", "action"}))
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class RolePermission {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "role_id", nullable = false)
|
||||||
|
private Long roleId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String module;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 30)
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "dept_scope", length = 10)
|
||||||
|
private DeptScope deptScope = DeptScope.OWN;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.accura.wbx.spring.rbac;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RolePermissionRepository extends JpaRepository<RolePermission, Long> {
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT CASE WHEN COUNT(rp) > 0 THEN true ELSE false END
|
||||||
|
FROM RolePermission rp
|
||||||
|
JOIN kr.co.accura.wbx.spring.rbac.WbxUserRole ur ON rp.roleId = ur.roleId
|
||||||
|
WHERE ur.userId = :userId AND rp.module = :module AND rp.action = :action
|
||||||
|
""")
|
||||||
|
boolean existsByUserIdAndModuleAndAction(@Param("userId") Long userId,
|
||||||
|
@Param("module") String module,
|
||||||
|
@Param("action") String action);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT rp.deptScope FROM RolePermission rp
|
||||||
|
JOIN kr.co.accura.wbx.spring.rbac.WbxUserRole ur ON rp.roleId = ur.roleId
|
||||||
|
WHERE ur.userId = :userId AND rp.module = :module AND rp.action = :action
|
||||||
|
ORDER BY CASE rp.deptScope WHEN 'COMPANY' THEN 3 WHEN 'DEPT' THEN 2 ELSE 1 END DESC
|
||||||
|
""")
|
||||||
|
Optional<DeptScope> findMaxDeptScope(@Param("userId") Long userId,
|
||||||
|
@Param("module") String module,
|
||||||
|
@Param("action") String action);
|
||||||
|
|
||||||
|
List<RolePermission> findByRoleId(Long roleId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
void deleteByRoleId(Long roleId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package kr.co.accura.wbx.spring.rbac;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_roles")
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxRole {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 30)
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "is_system")
|
||||||
|
private boolean isSystem = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.co.accura.wbx.spring.rbac;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wbx_user_roles",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id", "scope_id"}))
|
||||||
|
@Getter @Setter
|
||||||
|
@NoArgsConstructor @AllArgsConstructor @Builder
|
||||||
|
public class WbxUserRole {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "role_id", nullable = false)
|
||||||
|
private Long roleId;
|
||||||
|
|
||||||
|
@Column(name = "scope_type", length = 20)
|
||||||
|
private String scopeType; // PROJECT, DEPARTMENT, null
|
||||||
|
|
||||||
|
@Column(name = "scope_id")
|
||||||
|
private Long scopeId;
|
||||||
|
|
||||||
|
@Column(name = "granted_at")
|
||||||
|
private LocalDateTime grantedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.accura.wbx.spring.rbac;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WbxUserRoleRepository extends JpaRepository<WbxUserRole, Long> {
|
||||||
|
List<WbxUserRole> findByUserId(Long userId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
void deleteByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# ===== AWS 클라우드 프로필 =====
|
||||||
|
# 사용법: --spring.profiles.active=prod,postgresql,aws
|
||||||
|
# AWS Cognito SSO + S3 연동
|
||||||
|
|
||||||
|
spring:
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
client:
|
||||||
|
registration:
|
||||||
|
cognito:
|
||||||
|
client-id: ${AWS_COGNITO_CLIENT_ID}
|
||||||
|
client-secret: ${AWS_COGNITO_CLIENT_SECRET}
|
||||||
|
scope: openid,profile,email
|
||||||
|
provider:
|
||||||
|
cognito:
|
||||||
|
issuer-uri: https://cognito-idp.${AWS_REGION:ap-northeast-2}.amazonaws.com/${AWS_USER_POOL_ID}
|
||||||
|
|
||||||
|
wbx:
|
||||||
|
spring:
|
||||||
|
mfa:
|
||||||
|
enabled: true
|
||||||
|
force-for-external: true
|
||||||
|
file:
|
||||||
|
storage-type: aws-s3
|
||||||
|
aws:
|
||||||
|
bucket: ${AWS_S3_BUCKET}
|
||||||
|
region: ${AWS_REGION:ap-northeast-2}
|
||||||
|
access-key: ${AWS_ACCESS_KEY}
|
||||||
|
secret-key: ${AWS_SECRET_KEY}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ===== Azure 클라우드 프로필 =====
|
||||||
|
# 사용법: --spring.profiles.active=prod,mssql,azure
|
||||||
|
# Azure Entra SSO + Blob Storage + Key Vault 연동
|
||||||
|
|
||||||
|
spring:
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
client:
|
||||||
|
registration:
|
||||||
|
azure:
|
||||||
|
client-id: ${AZURE_CLIENT_ID}
|
||||||
|
client-secret: ${AZURE_CLIENT_SECRET}
|
||||||
|
scope: openid,profile,email
|
||||||
|
provider:
|
||||||
|
azure:
|
||||||
|
issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0
|
||||||
|
|
||||||
|
wbx:
|
||||||
|
spring:
|
||||||
|
mfa:
|
||||||
|
enabled: false # Azure Entra Conditional Access가 MFA 처리
|
||||||
|
force-for-external: true
|
||||||
|
file:
|
||||||
|
storage-type: azure-blob
|
||||||
|
azure:
|
||||||
|
account-name: ${AZURE_STORAGE_ACCOUNT}
|
||||||
|
account-key: ${AZURE_STORAGE_KEY}
|
||||||
|
container-name: ${AZURE_CONTAINER:uploads}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ===== MSSQL 프로필 =====
|
||||||
|
# 사용법: --spring.profiles.active=prod,mssql
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:sqlserver://${DB_HOST:localhost}:${DB_PORT:1433};databaseName=${DB_NAME:wbx_spring};encrypt=true;trustServerCertificate=true
|
||||||
|
username: ${DB_USER:sa}
|
||||||
|
password: ${DB_PASS:password}
|
||||||
|
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||||
|
minimum-idle: 5
|
||||||
|
connection-timeout: 30000
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.SQLServerDialect
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.SQLServerDialect
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
locations: classpath:db/migration/common,classpath:db/migration/mssql
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ===== MySQL 프로필 =====
|
||||||
|
# 사용법: --spring.profiles.active=prod,mysql
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:wbx_spring}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
|
||||||
|
username: ${DB_USER:wbxapp}
|
||||||
|
password: ${DB_PASS:password}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||||
|
minimum-idle: 5
|
||||||
|
connection-timeout: 30000
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.MySQLDialect
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.MySQLDialect
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
locations: classpath:db/migration/common,classpath:db/migration/mysql
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ===== Oracle 프로필 =====
|
||||||
|
# 사용법: --spring.profiles.active=prod,oracle
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:oracle:thin:@${DB_HOST:localhost}:${DB_PORT:1521}:${DB_SID:ORCL}
|
||||||
|
username: ${DB_USER:wbxapp}
|
||||||
|
password: ${DB_PASS:password}
|
||||||
|
driver-class-name: oracle.jdbc.OracleDriver
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||||
|
minimum-idle: 5
|
||||||
|
connection-timeout: 30000
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.OracleDialect
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.OracleDialect
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
locations: classpath:db/migration/common,classpath:db/migration/oracle
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ===== PostgreSQL 프로필 =====
|
||||||
|
# 사용법: --spring.profiles.active=prod,postgresql
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:wbx_spring}
|
||||||
|
username: ${DB_USER:wbxapp}
|
||||||
|
password: ${DB_PASS:password}
|
||||||
|
driver-class-name: org.postgresql.Driver
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: ${DB_POOL_SIZE:20}
|
||||||
|
minimum-idle: 5
|
||||||
|
connection-timeout: 30000
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
locations: classpath:db/migration/common,classpath:db/migration/postgresql
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ===== WBX Spring Framework — 프로덕션 프로필 =====
|
||||||
|
# 사용법: java -jar app.jar --spring.profiles.active=prod,mysql
|
||||||
|
# java -jar app.jar --spring.profiles.active=prod,postgresql
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
forward-headers-strategy: native
|
||||||
|
servlet:
|
||||||
|
context-path: ${SERVER_CONTEXT_PATH:/}
|
||||||
|
|
||||||
|
spring:
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate # 프로덕션: Flyway 사용, DDL 검증만
|
||||||
|
open-in-view: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
default_batch_fetch_size: 100
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
wbx:
|
||||||
|
spring:
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET}
|
||||||
|
expiration: 28800
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ORIGINS:https://app.company.com}
|
||||||
|
notification:
|
||||||
|
sse-enabled: true
|
||||||
|
heartbeat-seconds: 30
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: when-authorized
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
swagger-ui:
|
||||||
|
enabled: false # 프로덕션 Swagger 비활성화
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
kr.co.accura.wbx.spring: INFO
|
||||||
|
file:
|
||||||
|
name: ${LOG_PATH:/opt/wbx-app/logs/app.log}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ===== 테스트 프로필 =====
|
||||||
|
# 사용법: ./gradlew test (자동 적용)
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
|
||||||
|
wbx:
|
||||||
|
spring:
|
||||||
|
jwt:
|
||||||
|
secret: test-secret-key-minimum-256-bits-for-hmac-sha-algorithm
|
||||||
|
expiration: 3600
|
||||||
|
mfa:
|
||||||
|
enabled: false
|
||||||
|
file:
|
||||||
|
storage-type: local
|
||||||
|
upload-dir: ./build/test-uploads
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
kr.co.accura.wbx.spring: DEBUG
|
||||||
이 Diff에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기
새 Issue에서 참조
사용자 차단