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은 다음에 포함되어 있습니다:
2026-03-25 10:27:18 +09:00
커밋 476f8a0565
121개의 변경된 파일7596개의 추가작업 그리고 0개의 파일을 삭제

7
.dockerignore 일반 파일
파일 보기

@@ -0,0 +1,7 @@
.gradle
build
!build/libs/*.jar
.idea
*.iml
src/test
server.log

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 벤더링됨 일반 파일
파일 보기

@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

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 벤더링됨 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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
gradle/wrapper/gradle-wrapper.jar 벤더링됨 일반 파일

바이너리 파일은 표시되지 않습니다.

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 벤더링됨 일반 파일
파일 보기

@@ -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 벤더링됨 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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 일반 파일
파일 보기

@@ -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&lt;User&gt; 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에서 너무 많은 파일이 변경되어 일부 파일이 표시되지 않습니다 더 보기