commit 476f8a0565df4e5c0f7ca1be3c750ba09aca6904 Author: accura Date: Wed Mar 25 10:27:18 2026 +0900 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c8cd573 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.gradle +build +!build/libs/*.jar +.idea +*.iml +src/test +server.log diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1584bea --- /dev/null +++ b/.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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d02a9c --- /dev/null +++ b/.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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46c5936 --- /dev/null +++ b/.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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b15cdd --- /dev/null +++ b/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"] diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..37d43f0 --- /dev/null +++ b/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() +} diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..39cd9be --- /dev/null +++ b/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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e943228 --- /dev/null +++ b/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: diff --git a/docs/00-setup-guide.md b/docs/00-setup-guide.md new file mode 100644 index 0000000..ecc0822 --- /dev/null +++ b/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 증설 | diff --git a/docs/01-project-structure.md b/docs/01-project-structure.md new file mode 100644 index 0000000..f0bd230 --- /dev/null +++ b/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 | 결재 대기 | diff --git a/docs/WBX_Spring_Framework_개발자가이드.pdf b/docs/WBX_Spring_Framework_개발자가이드.pdf new file mode 100644 index 0000000..7703a55 Binary files /dev/null and b/docs/WBX_Spring_Framework_개발자가이드.pdf differ diff --git a/docs/WBX_Spring_Framework_설치가이드_Cloud.pdf b/docs/WBX_Spring_Framework_설치가이드_Cloud.pdf new file mode 100644 index 0000000..a7aa3ec Binary files /dev/null and b/docs/WBX_Spring_Framework_설치가이드_Cloud.pdf differ diff --git a/docs/WBX_Spring_Framework_설치가이드_OnPremise.pdf b/docs/WBX_Spring_Framework_설치가이드_OnPremise.pdf new file mode 100644 index 0000000..5e04b08 Binary files /dev/null and b/docs/WBX_Spring_Framework_설치가이드_OnPremise.pdf differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aaaabb3 --- /dev/null +++ b/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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/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 diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh new file mode 100644 index 0000000..7403f5d --- /dev/null +++ b/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 "" diff --git a/scripts/install.bat b/scripts/install.bat new file mode 100644 index 0000000..99b7659 --- /dev/null +++ b/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 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..0f1953e --- /dev/null +++ b/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 "" diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..7d4e6ef --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'wbx-spring-core' diff --git a/src/main/java/kr/co/accura/wbx/spring/HealthController.java b/src/main/java/kr/co/accura/wbx/spring/HealthController.java new file mode 100644 index 0000000..5807279 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/HealthController.java @@ -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 health() { + return Map.of("status", "ok", "app", "wbx-spring"); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/WbxSpringCoreApplication.java b/src/main/java/kr/co/accura/wbx/spring/WbxSpringCoreApplication.java new file mode 100644 index 0000000..1f893d3 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/WbxSpringCoreApplication.java @@ -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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/admin/AdminController.java b/src/main/java/kr/co/accura/wbx/spring/admin/AdminController.java new file mode 100644 index 0000000..6a06b3b --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/admin/AdminController.java @@ -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 listUsers(@RequestParam(defaultValue = "0") int skip, + @RequestParam(defaultValue = "20") int limit) { + Page 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 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 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 toggleStatus(@PathVariable Long id, @RequestBody Map 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 listRoles() { + List 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 getRolePermissions(@PathVariable Long id) { + List perms = permissionRepository.findAll() + .stream().filter(p -> p.getRoleId().equals(id)).toList(); + return Map.of("items", perms, "total", perms.size()); + } + + // ===== 로그인 이력 ===== + + @GetMapping("/login-history") + public Map loginHistory(@RequestParam(defaultValue = "0") int skip, + @RequestParam(defaultValue = "50") int limit) { + Page 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 systemHealth() { + return Map.of( + "users", userRepository.countByIsActiveTrue(), + "status", "ok" + ); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/admin/AdminLoginController.java b/src/main/java/kr/co/accura/wbx/spring/admin/AdminLoginController.java new file mode 100644 index 0000000..fc50097 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/admin/AdminLoginController.java @@ -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"; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/admin/AdminUserDetailsService.java b/src/main/java/kr/co/accura/wbx/spring/admin/AdminUserDetailsService.java new file mode 100644 index 0000000..f825a97 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/admin/AdminUserDetailsService.java @@ -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")) + ); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/admin/AdminViewController.java b/src/main/java/kr/co/accura/wbx/spring/admin/AdminViewController.java new file mode 100644 index 0000000..9d36abb --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/admin/AdminViewController.java @@ -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 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"; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/admin/WbxRoleRepository.java b/src/main/java/kr/co/accura/wbx/spring/admin/WbxRoleRepository.java new file mode 100644 index 0000000..b6c244a --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/admin/WbxRoleRepository.java @@ -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 { + Optional findByCode(String code); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/admin/WbxSystemConfigRepository.java b/src/main/java/kr/co/accura/wbx/spring/admin/WbxSystemConfigRepository.java new file mode 100644 index 0000000..fa5cf18 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/admin/WbxSystemConfigRepository.java @@ -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 { + Optional findByConfigKey(String configKey); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ActionRequest.java b/src/main/java/kr/co/accura/wbx/spring/approval/ActionRequest.java new file mode 100644 index 0000000..19c4401 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ActionRequest.java @@ -0,0 +1,3 @@ +package kr.co.accura.wbx.spring.approval; + +public record ActionRequest(String comment) {} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalCompletedEvent.java b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalCompletedEvent.java new file mode 100644 index 0000000..c1d6dbc --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalCompletedEvent.java @@ -0,0 +1,11 @@ +package kr.co.accura.wbx.spring.approval; + +/** + * 결재 완료 이벤트 — @EventListener로 후속 처리 + */ +public record ApprovalCompletedEvent( + String approvalType, + Long itemId, + Long approverId, + Object approval +) {} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHandler.java b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHandler.java new file mode 100644 index 0000000..aea310d --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHandler.java @@ -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 getPending(Long approverId, Pageable pageable); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHandlerRegistry.java b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHandlerRegistry.java new file mode 100644 index 0000000..5f4f1cc --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHandlerRegistry.java @@ -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 handlers; + + @Autowired + public ApprovalHandlerRegistry(List 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 getAll() { + return handlers.values(); + } + + public boolean hasHandler(String typeKey) { + return handlers.containsKey(typeKey); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHistoryDto.java b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHistoryDto.java new file mode 100644 index 0000000..8065cb4 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalHistoryDto.java @@ -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 approvalLines +) {} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalLineDto.java b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalLineDto.java new file mode 100644 index 0000000..72c3d26 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalLineDto.java @@ -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 +) {} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalPendingDto.java b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalPendingDto.java new file mode 100644 index 0000000..1dd570d --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalPendingDto.java @@ -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 +) {} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalResult.java b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalResult.java new file mode 100644 index 0000000..6ae94e9 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/ApprovalResult.java @@ -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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/approval/UnifiedApprovalController.java b/src/main/java/kr/co/accura/wbx/spring/approval/UnifiedApprovalController.java new file mode 100644 index 0000000..76b525b --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/approval/UnifiedApprovalController.java @@ -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 pending(@RequestParam(defaultValue = "0") int skip, + @RequestParam(defaultValue = "20") int limit) { + Long userId = SecurityUtils.getCurrentUserId(); + List 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() + ); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/audit/AuditLogRepository.java b/src/main/java/kr/co/accura/wbx/spring/audit/AuditLogRepository.java new file mode 100644 index 0000000..1ea4f5d --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/audit/AuditLogRepository.java @@ -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 { +} diff --git a/src/main/java/kr/co/accura/wbx/spring/audit/AuditLogService.java b/src/main/java/kr/co/accura/wbx/spring/audit/AuditLogService.java new file mode 100644 index 0000000..a8b7ef4 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/audit/AuditLogService.java @@ -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()); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/audit/WbxAuditLog.java b/src/main/java/kr/co/accura/wbx/spring/audit/WbxAuditLog.java new file mode 100644 index 0000000..05e1f14 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/audit/WbxAuditLog.java @@ -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(); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/ApiKeyFilter.java b/src/main/java/kr/co/accura/wbx/spring/auth/ApiKeyFilter.java new file mode 100644 index 0000000..2ef839a --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/ApiKeyFilter.java @@ -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 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; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/AuthController.java b/src/main/java/kr/co/accura/wbx/spring/auth/AuthController.java new file mode 100644 index 0000000..2150261 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/AuthController.java @@ -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 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 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 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 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 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 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 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 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 refresh(@RequestBody Map 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 logout(@AuthenticationPrincipal WbxUserDetails user, + @RequestBody(required = false) Map 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 + ) {} +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/JwtFilter.java b/src/main/java/kr/co/accura/wbx/spring/auth/JwtFilter.java new file mode 100644 index 0000000..c9f8498 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/JwtFilter.java @@ -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 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"); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/JwtProvider.java b/src/main/java/kr/co/accura/wbx/spring/auth/JwtProvider.java new file mode 100644 index 0000000..a56f5e0 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/JwtProvider.java @@ -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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/LoginHistoryRepository.java b/src/main/java/kr/co/accura/wbx/spring/auth/LoginHistoryRepository.java new file mode 100644 index 0000000..627c63c --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/LoginHistoryRepository.java @@ -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 { + + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + long countByAction(String action); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/MfaController.java b/src/main/java/kr/co/accura/wbx/spring/auth/MfaController.java new file mode 100644 index 0000000..7c28925 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/MfaController.java @@ -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 + *

+ * 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 setup(@AuthenticationPrincipal WbxUserDetails user) { + return mfaService.setupMfa(user.getId()); + } + + /** + * MFA 설정 완료 — TOTP 코드 검증 후 활성화 + 백업 코드 발급 + */ + @PostMapping("/setup/verify") + public Map verifySetup(@AuthenticationPrincipal WbxUserDetails user, + @Valid @RequestBody CodeRequest req) { + return mfaService.verifySetup(user.getId(), req.code()); + } + + /** + * MFA 비활성화 (본인 또는 SA) + */ + @DeleteMapping + public Map disable(@AuthenticationPrincipal WbxUserDetails user) { + mfaService.disableMfa(user.getId()); + return Map.of("detail", "MFA disabled"); + } + + /** + * 백업 코드로 인증 (로그인 2단계 대안) + */ + @PostMapping("/backup-verify") + public Map backupVerify(@RequestBody Map 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) {} +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/MfaService.java b/src/main/java/kr/co/accura/wbx/spring/auth/MfaService.java new file mode 100644 index 0000000..9835c77 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/MfaService.java @@ -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 서비스 — 표준 구현 + *

+ * wbx.spring.mfa.enabled=true 일 때 활성화. + * TOTP 라이브러리 의존성이 없어도 동작하도록 + * HMAC-SHA1 기반 RFC 6238 직접 구현. + *

+ * 고객 환경에 따라 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 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 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 backupCodes = generateBackupCodes(8); + List 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 hashedCodes = objectMapper.readValue( + totp.getBackupCodes(), new TypeReference>() {}); + + 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 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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/PasswordPolicy.java b/src/main/java/kr/co/accura/wbx/spring/auth/PasswordPolicy.java new file mode 100644 index 0000000..14e7582 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/PasswordPolicy.java @@ -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 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(); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/RefreshTokenRepository.java b/src/main/java/kr/co/accura/wbx/spring/auth/RefreshTokenRepository.java new file mode 100644 index 0000000..23c6148 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/RefreshTokenRepository.java @@ -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 { + + Optional 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); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/RefreshTokenService.java b/src/main/java/kr/co/accura/wbx/spring/auth/RefreshTokenService.java new file mode 100644 index 0000000..8076825 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/RefreshTokenService.java @@ -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); + } + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/SsoSuccessHandler.java b/src/main/java/kr/co/accura/wbx/spring/auth/SsoSuccessHandler.java new file mode 100644 index 0000000..2f50d0a --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/SsoSuccessHandler.java @@ -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) 로그인 성공 핸들러 + *

+ * OAuth2 로그인 성공 → WBX 호환 JWT 발급 → 프론트엔드 콜백 URL로 리다이렉트. + *

+ * 고객 환경 커스터마이즈: + * - 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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/TotpSecretRepository.java b/src/main/java/kr/co/accura/wbx/spring/auth/TotpSecretRepository.java new file mode 100644 index 0000000..71309a4 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/TotpSecretRepository.java @@ -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 { + Optional findByUserId(Long userId); + + @Modifying + @Transactional + void deleteByUserId(Long userId); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/WbxLoginHistory.java b/src/main/java/kr/co/accura/wbx/spring/auth/WbxLoginHistory.java new file mode 100644 index 0000000..57310ea --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/WbxLoginHistory.java @@ -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(); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/WbxRefreshToken.java b/src/main/java/kr/co/accura/wbx/spring/auth/WbxRefreshToken.java new file mode 100644 index 0000000..bf75d6f --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/WbxRefreshToken.java @@ -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(); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/WbxTotpSecret.java b/src/main/java/kr/co/accura/wbx/spring/auth/WbxTotpSecret.java new file mode 100644 index 0000000..b2d184a --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/WbxTotpSecret.java @@ -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; +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/WbxUser.java b/src/main/java/kr/co/accura/wbx/spring/auth/WbxUser.java new file mode 100644 index 0000000..7a5df11 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/WbxUser.java @@ -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(); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/WbxUserDetails.java b/src/main/java/kr/co/accura/wbx/spring/auth/WbxUserDetails.java new file mode 100644 index 0000000..88831c2 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/WbxUserDetails.java @@ -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 roles = new ArrayList<>(); + @Builder.Default + private boolean enabled = true; + + @Override + public Collection getAuthorities() { + List 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; } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/auth/WbxUserRepository.java b/src/main/java/kr/co/accura/wbx/spring/auth/WbxUserRepository.java new file mode 100644 index 0000000..f5fe536 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/auth/WbxUserRepository.java @@ -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 { + + Optional findByEmail(String email); + + Optional findByUsername(String username); + + Optional findByAzureOid(String azureOid); + + Optional findByEmployeeNumber(String employeeNumber); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + long countByIsActiveTrue(); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/common/BaseEntity.java b/src/main/java/kr/co/accura/wbx/spring/common/BaseEntity.java new file mode 100644 index 0000000..7f8f384 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/common/BaseEntity.java @@ -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; +} diff --git a/src/main/java/kr/co/accura/wbx/spring/common/BusinessException.java b/src/main/java/kr/co/accura/wbx/spring/common/BusinessException.java new file mode 100644 index 0000000..94c4152 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/common/BusinessException.java @@ -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; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/common/NotFoundException.java b/src/main/java/kr/co/accura/wbx/spring/common/NotFoundException.java new file mode 100644 index 0000000..1cddb1a --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/common/NotFoundException.java @@ -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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/common/SecurityUtils.java b/src/main/java/kr/co/accura/wbx/spring/common/SecurityUtils.java new file mode 100644 index 0000000..c9f1630 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/common/SecurityUtils.java @@ -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; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/compat/WbxErrorHandler.java b/src/main/java/kr/co/accura/wbx/spring/compat/WbxErrorHandler.java new file mode 100644 index 0000000..208042f --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/compat/WbxErrorHandler.java @@ -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> 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> handleNotFound(NotFoundException ex) { + return ResponseEntity.status(404).body(Map.of( + "detail", ex.getMessage() + )); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleForbidden(AccessDeniedException ex) { + return ResponseEntity.status(403).body(Map.of( + "detail", "Access denied" + )); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> 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> handleGeneral(Exception ex) { + log.error("Unhandled exception: {}", ex.getMessage(), ex); + return ResponseEntity.status(500).body(Map.of( + "detail", "Internal server error" + )); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/compat/WbxPaginationConfig.java b/src/main/java/kr/co/accura/wbx/spring/compat/WbxPaginationConfig.java new file mode 100644 index 0000000..7ccae48 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/compat/WbxPaginationConfig.java @@ -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 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); + } + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/config/CorsAutoConfig.java b/src/main/java/kr/co/accura/wbx/spring/config/CorsAutoConfig.java new file mode 100644 index 0000000..4528c67 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/config/CorsAutoConfig.java @@ -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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/config/OpenApiConfig.java b/src/main/java/kr/co/accura/wbx/spring/config/OpenApiConfig.java new file mode 100644 index 0000000..25b0b5e --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/config/OpenApiConfig.java @@ -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"))); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/config/SecurityAutoConfig.java b/src/main/java/kr/co/accura/wbx/spring/config/SecurityAutoConfig.java new file mode 100644 index 0000000..cb53c65 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/config/SecurityAutoConfig.java @@ -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(); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/config/WbxSpringProperties.java b/src/main/java/kr/co/accura/wbx/spring/config/WbxSpringProperties.java new file mode 100644 index 0000000..783e9e6 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/config/WbxSpringProperties.java @@ -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 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 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"; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/config/WbxSystemConfig.java b/src/main/java/kr/co/accura/wbx/spring/config/WbxSystemConfig.java new file mode 100644 index 0000000..f9655af --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/config/WbxSystemConfig.java @@ -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; +} diff --git a/src/main/java/kr/co/accura/wbx/spring/datasource/DataSource.java b/src/main/java/kr/co/accura/wbx/spring/datasource/DataSource.java new file mode 100644 index 0000000..5a2925c --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/datasource/DataSource.java @@ -0,0 +1,23 @@ +package kr.co.accura.wbx.spring.datasource; + +import java.lang.annotation.*; + +/** + * 데이터소스 전환 어노테이션 + *

+ * 메서드 또는 클래스에 사용하여 특정 데이터소스를 지정합니다. + *

+ * {@literal @}DataSource("readonly")
+ * public List<User> findAll() { ... }
+ * 
+ * + * @see WbxRoutingDataSource + * @see DataSourceAspect + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DataSource { + /** 데이터소스 이름: "app" (기본), "wbxgw", "readonly" */ + String value() default "app"; +} diff --git a/src/main/java/kr/co/accura/wbx/spring/datasource/DataSourceAspect.java b/src/main/java/kr/co/accura/wbx/spring/datasource/DataSourceAspect.java new file mode 100644 index 0000000..aaa647b --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/datasource/DataSourceAspect.java @@ -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 + *

+ * 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(); + } + } + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/datasource/MultiDataSourceConfig.java b/src/main/java/kr/co/accura/wbx/spring/datasource/MultiDataSourceConfig.java new file mode 100644 index 0000000..9251a29 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/datasource/MultiDataSourceConfig.java @@ -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 설정 (선택적 활성화) + *

+ * wbx.spring.datasource.routing-enabled=true 일 때 활성화. + * 기본: 단일 DataSource (Spring Boot 자동 설정). + *

+ * 사용법 (application.yml): + *

+ * 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://...
+ * 
+ */ +@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 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; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/datasource/WbxRoutingDataSource.java b/src/main/java/kr/co/accura/wbx/spring/datasource/WbxRoutingDataSource.java new file mode 100644 index 0000000..60f956e --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/datasource/WbxRoutingDataSource.java @@ -0,0 +1,31 @@ +package kr.co.accura.wbx.spring.datasource; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; + +/** + * ThreadLocal 기반 동적 데이터소스 라우팅 + *

+ * DataSourceAspect가 @DataSource 어노테이션을 감지하여 + * ThreadLocal에 데이터소스 키를 설정하면, 이 클래스가 해당 키를 반환합니다. + */ +public class WbxRoutingDataSource extends AbstractRoutingDataSource { + + private static final ThreadLocal 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(); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/file/AwsS3StorageService.java b/src/main/java/kr/co/accura/wbx/spring/file/AwsS3StorageService.java new file mode 100644 index 0000000..9e03b88 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/file/AwsS3StorageService.java @@ -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 스토리지 구현 (표준 템플릿) + *

+ * ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다: + *

+ * // build.gradle
+ * implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.0'
+ *
+ * // 교체 후: S3Client.putObject() / getObject() / deleteObject() 사용
+ * 
+ *

+ * 현재 구현은 Pre-signed URL 방식을 사용하며, + * AWS CLI 인증(~/.aws/credentials)이 설정된 환경에서 동작합니다. + *

+ * 설정: + *

+ * 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}
+ * 
+ */ +@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 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 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() : ""; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/file/AzureBlobStorageService.java b/src/main/java/kr/co/accura/wbx/spring/file/AzureBlobStorageService.java new file mode 100644 index 0000000..baee5f3 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/file/AzureBlobStorageService.java @@ -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 구현 + *

+ * Azure SDK 없이 REST API 직접 호출 (의존성 최소화). + * 고객사에 Azure SDK 사용 가능하면 spring-cloud-azure-starter-storage-blob으로 교체 권장. + *

+ * 설정: + *

+ * 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
+ * 
+ */ +@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 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 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() : ""; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/file/FileStorageService.java b/src/main/java/kr/co/accura/wbx/spring/file/FileStorageService.java new file mode 100644 index 0000000..48240ee --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/file/FileStorageService.java @@ -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 ""; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/file/GcpStorageService.java b/src/main/java/kr/co/accura/wbx/spring/file/GcpStorageService.java new file mode 100644 index 0000000..fd165cc --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/file/GcpStorageService.java @@ -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 구현 (표준 템플릿) + *

+ * ⚠️ 프로덕션 배포 시 아래 SDK 기반 구현으로 교체를 권장합니다: + *

+ * // build.gradle
+ * implementation 'com.google.cloud:spring-cloud-gcp-starter-storage:5.9.0'
+ *
+ * // 교체 후: Storage.create() / readAllBytes() / delete() 사용
+ * 
+ *

+ * 현재 구현은 GOOGLE_APPLICATION_CREDENTIALS 환경변수(서비스 계정 JSON)로 + * 인증된 환경에서 GCP JSON API를 직접 호출합니다. + *

+ * 설정: + *

+ * wbx.spring.file.storage-type: gcp-storage
+ * wbx.spring.file.gcp.bucket: ${GCP_STORAGE_BUCKET}
+ * wbx.spring.file.gcp.project-id: ${GCP_PROJECT_ID}
+ * 
+ */ +@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 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 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() : ""; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/file/LocalFileStorageService.java b/src/main/java/kr/co/accura/wbx/spring/file/LocalFileStorageService.java new file mode 100644 index 0000000..51ec9bf --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/file/LocalFileStorageService.java @@ -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() : ""; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/file/WbxFileUpload.java b/src/main/java/kr/co/accura/wbx/spring/file/WbxFileUpload.java new file mode 100644 index 0000000..9c98963 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/file/WbxFileUpload.java @@ -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(); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/notification/Notification.java b/src/main/java/kr/co/accura/wbx/spring/notification/Notification.java new file mode 100644 index 0000000..bb57165 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/notification/Notification.java @@ -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(); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/notification/NotificationController.java b/src/main/java/kr/co/accura/wbx/spring/notification/NotificationController.java new file mode 100644 index 0000000..7bb365c --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/notification/NotificationController.java @@ -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 unreadCount(@AuthenticationPrincipal WbxUserDetails user) { + return Map.of("count", 0); // TODO: DB 연동 후 구현 + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/notification/NotificationDto.java b/src/main/java/kr/co/accura/wbx/spring/notification/NotificationDto.java new file mode 100644 index 0000000..7e5b5c9 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/notification/NotificationDto.java @@ -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(); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/notification/SseNotificationService.java b/src/main/java/kr/co/accura/wbx/spring/notification/SseNotificationService.java new file mode 100644 index 0000000..6260437 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/notification/SseNotificationService.java @@ -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 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(); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/rbac/DeptScope.java b/src/main/java/kr/co/accura/wbx/spring/rbac/DeptScope.java new file mode 100644 index 0000000..1fa32db --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/rbac/DeptScope.java @@ -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 Specification 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 + }; + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/rbac/PermissionEvaluator.java b/src/main/java/kr/co/accura/wbx/spring/rbac/PermissionEvaluator.java new file mode 100644 index 0000000..4787885 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/rbac/PermissionEvaluator.java @@ -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); + } +} diff --git a/src/main/java/kr/co/accura/wbx/spring/rbac/RolePermission.java b/src/main/java/kr/co/accura/wbx/spring/rbac/RolePermission.java new file mode 100644 index 0000000..bfc4312 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/rbac/RolePermission.java @@ -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; +} diff --git a/src/main/java/kr/co/accura/wbx/spring/rbac/RolePermissionRepository.java b/src/main/java/kr/co/accura/wbx/spring/rbac/RolePermissionRepository.java new file mode 100644 index 0000000..b8b37e8 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/rbac/RolePermissionRepository.java @@ -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 { + + @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 findMaxDeptScope(@Param("userId") Long userId, + @Param("module") String module, + @Param("action") String action); + + List findByRoleId(Long roleId); + + @Modifying + @Transactional + void deleteByRoleId(Long roleId); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/rbac/WbxRole.java b/src/main/java/kr/co/accura/wbx/spring/rbac/WbxRole.java new file mode 100644 index 0000000..d5508a5 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/rbac/WbxRole.java @@ -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; +} diff --git a/src/main/java/kr/co/accura/wbx/spring/rbac/WbxUserRole.java b/src/main/java/kr/co/accura/wbx/spring/rbac/WbxUserRole.java new file mode 100644 index 0000000..7a8ef40 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/rbac/WbxUserRole.java @@ -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(); +} diff --git a/src/main/java/kr/co/accura/wbx/spring/rbac/WbxUserRoleRepository.java b/src/main/java/kr/co/accura/wbx/spring/rbac/WbxUserRoleRepository.java new file mode 100644 index 0000000..6eb4956 --- /dev/null +++ b/src/main/java/kr/co/accura/wbx/spring/rbac/WbxUserRoleRepository.java @@ -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 { + List findByUserId(Long userId); + + @Modifying + @Transactional + void deleteByUserId(Long userId); +} diff --git a/src/main/resources/application-aws.yml b/src/main/resources/application-aws.yml new file mode 100644 index 0000000..789ab78 --- /dev/null +++ b/src/main/resources/application-aws.yml @@ -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} diff --git a/src/main/resources/application-azure.yml b/src/main/resources/application-azure.yml new file mode 100644 index 0000000..771f662 --- /dev/null +++ b/src/main/resources/application-azure.yml @@ -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} diff --git a/src/main/resources/application-mssql.yml b/src/main/resources/application-mssql.yml new file mode 100644 index 0000000..9d10acc --- /dev/null +++ b/src/main/resources/application-mssql.yml @@ -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 diff --git a/src/main/resources/application-mysql.yml b/src/main/resources/application-mysql.yml new file mode 100644 index 0000000..882eecf --- /dev/null +++ b/src/main/resources/application-mysql.yml @@ -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 diff --git a/src/main/resources/application-oracle.yml b/src/main/resources/application-oracle.yml new file mode 100644 index 0000000..0552737 --- /dev/null +++ b/src/main/resources/application-oracle.yml @@ -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 diff --git a/src/main/resources/application-postgresql.yml b/src/main/resources/application-postgresql.yml new file mode 100644 index 0000000..9478d24 --- /dev/null +++ b/src/main/resources/application-postgresql.yml @@ -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 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e5046a7 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -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} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..18cdd23 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -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 --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..dab13a5 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,69 @@ +spring: + application: + name: wbx-spring-core + + jpa: + hibernate: + ddl-auto: update + open-in-view: false + database-platform: org.hibernate.dialect.MySQLDialect + properties: + hibernate: + default_batch_fetch_size: 100 + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + + flyway: + enabled: false # 개발 시 hibernate ddl-auto 사용, 프로덕션 시 true + + 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:accura} + password: ${DB_PASS:ubuilder0117} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 + + data: + redis: + host: localhost + port: 6379 + +server: + port: 8080 + forward-headers-strategy: native + servlet: + context-path: ${SERVER_CONTEXT_PATH:/spring} # 독립 도메인 시 '/' 또는 제거 + +# WBX Spring Framework +wbx: + spring: + api-prefix: /api + jwt: + secret: ${JWT_SECRET:wbx-spring-dev-secret-key-change-in-production-minimum-256-bits-long} + expiration: 28800 + cors: + allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:3000,http://localhost:8080} + notification: + sse-enabled: true + heartbeat-seconds: 30 + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when-authorized + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui + packages-to-scan: kr.co.accura.wbx.spring + +spring.mvc.problemdetail.enabled: false diff --git a/src/main/resources/db/migration/common/V001__seed_roles.sql b/src/main/resources/db/migration/common/V001__seed_roles.sql new file mode 100644 index 0000000..fc58623 --- /dev/null +++ b/src/main/resources/db/migration/common/V001__seed_roles.sql @@ -0,0 +1,8 @@ +-- WBX Spring Framework — 초기 역할 데이터 +INSERT IGNORE INTO wbx_roles (code, name, description, is_system) VALUES + ('SA', 'System Administrator', '전체 시스템 관리', true), + ('PM', 'Project Manager', '프로젝트 관리, 최종 결재', true), + ('PCM', 'Project Control Mgr', 'WBS/TEAL 관리', true), + ('PTK', 'Project Timekeeper', '시수 관리', true), + ('DL', 'Discipline Lead', '1차 결재, Discipline 관리', true), + ('USER', 'General User', '일반 사용자', true); diff --git a/src/main/resources/db/migration/common/V002__seed_system_config.sql b/src/main/resources/db/migration/common/V002__seed_system_config.sql new file mode 100644 index 0000000..16137c2 --- /dev/null +++ b/src/main/resources/db/migration/common/V002__seed_system_config.sql @@ -0,0 +1,10 @@ +-- WBX Spring Framework — 초기 시스템 설정 +INSERT IGNORE INTO wbx_system_config (config_key, config_value, value_type, description) VALUES + ('auth.max_failed_attempts', '5', 'INT', '최대 로그인 실패 횟수'), + ('auth.lockout_minutes', '15', 'INT', '계정 잠금 시간(분)'), + ('auth.password_expiry_days', '90', 'INT', '비밀번호 만료 기간(일)'), + ('auth.session_timeout_minutes', '480', 'INT', '세션 타임아웃(분)'), + ('notification.sse_heartbeat_seconds', '30', 'INT', 'SSE 하트비트 주기(초)'), + ('file.max_upload_size_mb', '50', 'INT', '최대 업로드 크기(MB)'), + ('app.timezone', 'Asia/Seoul', 'STRING', '시스템 타임존'), + ('app.date_format', 'yyyy-MM-dd', 'STRING', '날짜 표시 형식'); diff --git a/src/main/resources/db/migration/mssql/V001__create_tables.sql b/src/main/resources/db/migration/mssql/V001__create_tables.sql new file mode 100644 index 0000000..9d6642e --- /dev/null +++ b/src/main/resources/db/migration/mssql/V001__create_tables.sql @@ -0,0 +1,134 @@ +-- WBX Spring Framework — MSSQL 스키마 + +CREATE TABLE wbx_users ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + email NVARCHAR(255) NOT NULL UNIQUE, + username NVARCHAR(100) NOT NULL UNIQUE, + hashed_password NVARCHAR(500), + full_name NVARCHAR(255), + phone NVARCHAR(50), + department_id BIGINT, + position_title NVARCHAR(100), + employee_number NVARCHAR(50) UNIQUE, + is_active BIT DEFAULT 1, + is_admin BIT DEFAULT 0, + mfa_enabled BIT DEFAULT 0, + azure_oid NVARCHAR(255), + sso_provider NVARCHAR(50), + failed_login_attempts INT DEFAULT 0, + last_failed_login DATETIME2, + locked_until DATETIME2, + password_changed_at DATETIME2, + must_change_password BIT DEFAULT 0, + last_login_at DATETIME2, + created_at DATETIME2 DEFAULT GETDATE(), + updated_at DATETIME2 DEFAULT GETDATE(), + created_by BIGINT, + updated_by BIGINT +); + +CREATE TABLE wbx_roles ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + code NVARCHAR(50) NOT NULL UNIQUE, + name NVARCHAR(100) NOT NULL, + description NVARCHAR(500), + is_system BIT DEFAULT 0, + created_at DATETIME2 DEFAULT GETDATE(), + updated_at DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE wbx_user_roles ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id), + role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id), + CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id) +); + +CREATE TABLE wbx_role_permissions ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + role_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_roles(id), + module NVARCHAR(100) NOT NULL, + action NVARCHAR(50) NOT NULL, + dept_scope NVARCHAR(20) DEFAULT 'OWN', + CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action) +); + +CREATE TABLE wbx_refresh_tokens ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id), + token_hash NVARCHAR(500) NOT NULL, + device_info NVARCHAR(500), + ip_address NVARCHAR(50), + expires_at DATETIME2 NOT NULL, + created_at DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE wbx_totp_secrets ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + user_id BIGINT NOT NULL UNIQUE FOREIGN KEY REFERENCES wbx_users(id), + encrypted_secret NVARCHAR(500) NOT NULL, + verified BIT DEFAULT 0, + backup_codes NVARCHAR(2000), + created_at DATETIME2 DEFAULT GETDATE(), + updated_at DATETIME2 DEFAULT GETDATE(), + created_by BIGINT, + updated_by BIGINT +); + +CREATE TABLE wbx_login_history ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + user_id BIGINT, + email NVARCHAR(255), + action NVARCHAR(50), + auth_method NVARCHAR(50), + ip_address NVARCHAR(50), + user_agent NVARCHAR(500), + failure_reason NVARCHAR(500), + created_at DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE wbx_notifications ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + user_id BIGINT NOT NULL FOREIGN KEY REFERENCES wbx_users(id), + title NVARCHAR(500), + message NVARCHAR(MAX), + type NVARCHAR(50), + is_read BIT DEFAULT 0, + link NVARCHAR(1000), + created_at DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE wbx_audit_logs ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + user_id BIGINT, + username NVARCHAR(255), + action NVARCHAR(50) NOT NULL, + resource NVARCHAR(100) NOT NULL, + resource_id BIGINT, + detail NVARCHAR(4000), + ip_address NVARCHAR(50), + created_at DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE wbx_file_uploads ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + user_id BIGINT, + original_name NVARCHAR(500), + stored_name NVARCHAR(500), + file_key NVARCHAR(500), + content_type NVARCHAR(200), + file_size BIGINT, + category NVARCHAR(100), + created_at DATETIME2 DEFAULT GETDATE() +); + +CREATE TABLE wbx_system_config ( + id BIGINT IDENTITY(1,1) PRIMARY KEY, + config_key NVARCHAR(100) NOT NULL UNIQUE, + config_value NVARCHAR(4000), + value_type NVARCHAR(20) DEFAULT 'STRING', + description NVARCHAR(500), + is_editable BIT DEFAULT 1, + updated_at DATETIME2 DEFAULT GETDATE(), + updated_by BIGINT +); diff --git a/src/main/resources/db/migration/mysql/V001__create_tables.sql b/src/main/resources/db/migration/mysql/V001__create_tables.sql new file mode 100644 index 0000000..d502025 --- /dev/null +++ b/src/main/resources/db/migration/mysql/V001__create_tables.sql @@ -0,0 +1,141 @@ +-- WBX Spring Framework — MySQL 스키마 (utf8mb4) +-- Hibernate ddl-auto와 병행 사용 가능 (Flyway 활성화 시 이 파일 사용) + +CREATE TABLE IF NOT EXISTS wbx_users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + username VARCHAR(100) NOT NULL UNIQUE, + hashed_password VARCHAR(500), + full_name VARCHAR(255), + phone VARCHAR(50), + department_id BIGINT, + position_title VARCHAR(100), + employee_number VARCHAR(50) UNIQUE, + is_active TINYINT(1) DEFAULT 1, + is_admin TINYINT(1) DEFAULT 0, + mfa_enabled TINYINT(1) DEFAULT 0, + azure_oid VARCHAR(255), + sso_provider VARCHAR(50), + failed_login_attempts INT DEFAULT 0, + last_failed_login DATETIME, + locked_until DATETIME, + password_changed_at DATETIME, + must_change_password TINYINT(1) DEFAULT 0, + last_login_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by BIGINT, + updated_by BIGINT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS wbx_roles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + is_system TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS wbx_user_roles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + UNIQUE KEY uk_user_role (user_id, role_id), + FOREIGN KEY (user_id) REFERENCES wbx_users(id), + FOREIGN KEY (role_id) REFERENCES wbx_roles(id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_role_permissions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + role_id BIGINT NOT NULL, + module VARCHAR(100) NOT NULL, + action VARCHAR(50) NOT NULL, + dept_scope VARCHAR(20) DEFAULT 'OWN', + UNIQUE KEY uk_role_perm (role_id, module, action), + FOREIGN KEY (role_id) REFERENCES wbx_roles(id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_refresh_tokens ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(500) NOT NULL, + device_info VARCHAR(500), + ip_address VARCHAR(50), + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES wbx_users(id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_totp_secrets ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL UNIQUE, + encrypted_secret VARCHAR(500) NOT NULL, + verified TINYINT(1) DEFAULT 0, + backup_codes VARCHAR(2000), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by BIGINT, + updated_by BIGINT, + FOREIGN KEY (user_id) REFERENCES wbx_users(id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_login_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT, + email VARCHAR(255), + action VARCHAR(50), + auth_method VARCHAR(50), + ip_address VARCHAR(50), + user_agent VARCHAR(500), + failure_reason VARCHAR(500), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_notifications ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(500), + message TEXT, + type VARCHAR(50), + is_read TINYINT(1) DEFAULT 0, + link VARCHAR(1000), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES wbx_users(id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_audit_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT, + username VARCHAR(255), + action VARCHAR(50) NOT NULL, + resource VARCHAR(100) NOT NULL, + resource_id BIGINT, + detail VARCHAR(4000), + ip_address VARCHAR(50), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_file_uploads ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT, + original_name VARCHAR(500), + stored_name VARCHAR(500), + file_key VARCHAR(500), + content_type VARCHAR(200), + file_size BIGINT, + category VARCHAR(100), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS wbx_system_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value VARCHAR(4000), + value_type VARCHAR(20) DEFAULT 'STRING', + description VARCHAR(500), + is_editable TINYINT(1) DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + updated_by BIGINT +) ENGINE=InnoDB; diff --git a/src/main/resources/db/migration/oracle/V001__create_tables.sql b/src/main/resources/db/migration/oracle/V001__create_tables.sql new file mode 100644 index 0000000..ca75ced --- /dev/null +++ b/src/main/resources/db/migration/oracle/V001__create_tables.sql @@ -0,0 +1,134 @@ +-- WBX Spring Framework — Oracle 스키마 + +CREATE TABLE wbx_users ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email VARCHAR2(255) NOT NULL UNIQUE, + username VARCHAR2(100) NOT NULL UNIQUE, + hashed_password VARCHAR2(500), + full_name VARCHAR2(255), + phone VARCHAR2(50), + department_id NUMBER(19), + position_title VARCHAR2(100), + employee_number VARCHAR2(50) UNIQUE, + is_active NUMBER(1) DEFAULT 1, + is_admin NUMBER(1) DEFAULT 0, + mfa_enabled NUMBER(1) DEFAULT 0, + azure_oid VARCHAR2(255), + sso_provider VARCHAR2(50), + failed_login_attempts NUMBER(10) DEFAULT 0, + last_failed_login TIMESTAMP, + locked_until TIMESTAMP, + password_changed_at TIMESTAMP, + must_change_password NUMBER(1) DEFAULT 0, + last_login_at TIMESTAMP, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP, + created_by NUMBER(19), + updated_by NUMBER(19) +); + +CREATE TABLE wbx_roles ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + code VARCHAR2(50) NOT NULL UNIQUE, + name VARCHAR2(100) NOT NULL, + description VARCHAR2(500), + is_system NUMBER(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP +); + +CREATE TABLE wbx_user_roles ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id), + role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id), + CONSTRAINT uk_wbx_user_role UNIQUE (user_id, role_id) +); + +CREATE TABLE wbx_role_permissions ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + role_id NUMBER(19) NOT NULL REFERENCES wbx_roles(id), + module VARCHAR2(100) NOT NULL, + action VARCHAR2(50) NOT NULL, + dept_scope VARCHAR2(20) DEFAULT 'OWN', + CONSTRAINT uk_wbx_role_perm UNIQUE (role_id, module, action) +); + +CREATE TABLE wbx_refresh_tokens ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id), + token_hash VARCHAR2(500) NOT NULL, + device_info VARCHAR2(500), + ip_address VARCHAR2(50), + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP +); + +CREATE TABLE wbx_totp_secrets ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL UNIQUE REFERENCES wbx_users(id), + encrypted_secret VARCHAR2(500) NOT NULL, + verified NUMBER(1) DEFAULT 0, + backup_codes VARCHAR2(2000), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP, + created_by NUMBER(19), + updated_by NUMBER(19) +); + +CREATE TABLE wbx_login_history ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19), + email VARCHAR2(255), + action VARCHAR2(50), + auth_method VARCHAR2(50), + ip_address VARCHAR2(50), + user_agent VARCHAR2(500), + failure_reason VARCHAR2(500), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP +); + +CREATE TABLE wbx_notifications ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19) NOT NULL REFERENCES wbx_users(id), + title VARCHAR2(500), + message CLOB, + type VARCHAR2(50), + is_read NUMBER(1) DEFAULT 0, + link VARCHAR2(1000), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP +); + +CREATE TABLE wbx_audit_logs ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19), + username VARCHAR2(255), + action VARCHAR2(50) NOT NULL, + resource VARCHAR2(100) NOT NULL, + resource_id NUMBER(19), + detail VARCHAR2(4000), + ip_address VARCHAR2(50), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP +); + +CREATE TABLE wbx_file_uploads ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id NUMBER(19), + original_name VARCHAR2(500), + stored_name VARCHAR2(500), + file_key VARCHAR2(500), + content_type VARCHAR2(200), + file_size NUMBER(19), + category VARCHAR2(100), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP +); + +CREATE TABLE wbx_system_config ( + id NUMBER(19) GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + config_key VARCHAR2(100) NOT NULL UNIQUE, + config_value VARCHAR2(4000), + value_type VARCHAR2(20) DEFAULT 'STRING', + description VARCHAR2(500), + is_editable NUMBER(1) DEFAULT 1, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP, + updated_by NUMBER(19) +); diff --git a/src/main/resources/db/migration/postgresql/V001__create_tables.sql b/src/main/resources/db/migration/postgresql/V001__create_tables.sql new file mode 100644 index 0000000..256ec45 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V001__create_tables.sql @@ -0,0 +1,134 @@ +-- WBX Spring Framework — PostgreSQL 스키마 + +CREATE TABLE IF NOT EXISTS wbx_users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + username VARCHAR(100) NOT NULL UNIQUE, + hashed_password VARCHAR(500), + full_name VARCHAR(255), + phone VARCHAR(50), + department_id BIGINT, + position_title VARCHAR(100), + employee_number VARCHAR(50) UNIQUE, + is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, + mfa_enabled BOOLEAN DEFAULT FALSE, + azure_oid VARCHAR(255), + sso_provider VARCHAR(50), + failed_login_attempts INT DEFAULT 0, + last_failed_login TIMESTAMP, + locked_until TIMESTAMP, + password_changed_at TIMESTAMP, + must_change_password BOOLEAN DEFAULT FALSE, + last_login_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT +); + +CREATE TABLE IF NOT EXISTS wbx_roles ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + is_system BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wbx_user_roles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES wbx_users(id), + role_id BIGINT NOT NULL REFERENCES wbx_roles(id), + UNIQUE (user_id, role_id) +); + +CREATE TABLE IF NOT EXISTS wbx_role_permissions ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL REFERENCES wbx_roles(id), + module VARCHAR(100) NOT NULL, + action VARCHAR(50) NOT NULL, + dept_scope VARCHAR(20) DEFAULT 'OWN', + UNIQUE (role_id, module, action) +); + +CREATE TABLE IF NOT EXISTS wbx_refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES wbx_users(id), + token_hash VARCHAR(500) NOT NULL, + device_info VARCHAR(500), + ip_address VARCHAR(50), + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wbx_totp_secrets ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL UNIQUE REFERENCES wbx_users(id), + encrypted_secret VARCHAR(500) NOT NULL, + verified BOOLEAN DEFAULT FALSE, + backup_codes VARCHAR(2000), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT +); + +CREATE TABLE IF NOT EXISTS wbx_login_history ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + email VARCHAR(255), + action VARCHAR(50), + auth_method VARCHAR(50), + ip_address VARCHAR(50), + user_agent VARCHAR(500), + failure_reason VARCHAR(500), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wbx_notifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES wbx_users(id), + title VARCHAR(500), + message TEXT, + type VARCHAR(50), + is_read BOOLEAN DEFAULT FALSE, + link VARCHAR(1000), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wbx_audit_logs ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + username VARCHAR(255), + action VARCHAR(50) NOT NULL, + resource VARCHAR(100) NOT NULL, + resource_id BIGINT, + detail VARCHAR(4000), + ip_address VARCHAR(50), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wbx_file_uploads ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + original_name VARCHAR(500), + stored_name VARCHAR(500), + file_key VARCHAR(500), + content_type VARCHAR(200), + file_size BIGINT, + category VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wbx_system_config ( + id BIGSERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value VARCHAR(4000), + value_type VARCHAR(20) DEFAULT 'STRING', + description VARCHAR(500), + is_editable BOOLEAN DEFAULT TRUE, + updated_at TIMESTAMP DEFAULT NOW(), + updated_by BIGINT +); diff --git a/src/main/resources/static/admin/css/admin.css b/src/main/resources/static/admin/css/admin.css new file mode 100644 index 0000000..c94cf89 --- /dev/null +++ b/src/main/resources/static/admin/css/admin.css @@ -0,0 +1,64 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: 'Malgun Gothic', -apple-system, sans-serif; background: #f0f2f5; color: #333; } + +/* Layout */ +.admin-layout { display: flex; min-height: 100vh; } +.admin-sidebar { width: 220px; background: #1e3c78; color: #fff; padding: 0; flex-shrink: 0; } +.admin-sidebar .logo { padding: 20px; font-size: 18px; font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.1); } +.admin-sidebar nav a { display: block; padding: 12px 20px; color: rgba(255,255,255,0.7); text-decoration: none; font-size: 14px; border-left: 3px solid transparent; } +.admin-sidebar nav a:hover { background: rgba(255,255,255,0.05); color: #fff; } +.admin-sidebar nav a.active { background: rgba(255,255,255,0.1); color: #fff; border-left-color: #4da6ff; } +.admin-content { flex: 1; padding: 24px; } + +/* Header */ +.page-header { margin-bottom: 24px; } +.page-header h1 { font-size: 22px; color: #1e3c78; } +.page-header p { color: #888; font-size: 13px; margin-top: 4px; } + +/* Cards */ +.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } +.stat-card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } +.stat-card .label { font-size: 13px; color: #888; } +.stat-card .value { font-size: 28px; font-weight: bold; color: #1e3c78; margin-top: 4px; } +.stat-card .value.green { color: #2e7d32; } +.stat-card .value.orange { color: #e65100; } + +/* Table */ +.data-table { width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } +.data-table table { width: 100%; border-collapse: collapse; } +.data-table th { background: #1e3c78; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; font-weight: 500; } +.data-table td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; font-size: 13px; } +.data-table tr:hover td { background: #f8f9ff; } + +/* Badges */ +.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; } +.badge-success { background: #e8f5e9; color: #2e7d32; } +.badge-danger { background: #ffebee; color: #c62828; } +.badge-warning { background: #fff8e1; color: #e65100; } +.badge-info { background: #e3f2fd; color: #1565c0; } + +/* Buttons */ +.btn { display: inline-block; padding: 6px 14px; border-radius: 4px; font-size: 13px; text-decoration: none; border: none; cursor: pointer; } +.btn-primary { background: #1e3c78; color: #fff; } +.btn-danger { background: #c62828; color: #fff; } +.btn-outline { background: #fff; border: 1px solid #ddd; color: #555; } +.btn:hover { opacity: 0.85; } + +/* Alert */ +.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; } +.alert-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; } +.alert-info { background: #e3f2fd; color: #1565c0; } + +/* Detail */ +.detail-grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px 16px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; } +.detail-grid .label { font-weight: 500; color: #888; font-size: 13px; } +.detail-grid .value { font-size: 14px; } + +/* Login */ +.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #1e3c78; } +.login-box { background: #fff; padding: 40px; border-radius: 12px; width: 380px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); } +.login-box h2 { text-align: center; color: #1e3c78; margin-bottom: 24px; } +.login-box input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; margin-bottom: 12px; font-size: 14px; } +.login-box button { width: 100%; padding: 12px; background: #1e3c78; color: #fff; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; } +.login-box button:hover { background: #15306a; } +.login-box .error { color: #c62828; font-size: 13px; text-align: center; margin-bottom: 12px; } diff --git a/src/main/resources/templates/admin/audit-logs.html b/src/main/resources/templates/admin/audit-logs.html new file mode 100644 index 0000000..77247d8 --- /dev/null +++ b/src/main/resources/templates/admin/audit-logs.html @@ -0,0 +1,28 @@ + + +WBX Admin - Audit Logs + +
+
+
+ +
+ + + + + + + + + + + + + +
시간사용자ID액션리소스리소스IDIP상세
+
+
+
+ + diff --git a/src/main/resources/templates/admin/config.html b/src/main/resources/templates/admin/config.html new file mode 100644 index 0000000..f1deb52 --- /dev/null +++ b/src/main/resources/templates/admin/config.html @@ -0,0 +1,39 @@ + + +WBX Admin - System Config + +
+
+
+ +
+ + +
+

설정 추가/수정

+
+
+
+
+ +
+
+ + +
+ + + + + + + + + + +
KeyValue설명수정일
+
+
+
+ + diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 0000000..728d6fe --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,43 @@ + + + + + WBX Admin - Dashboard + + + +
+
+
+ + +
+
+
활성 사용자
+
0
+
+
+
전체 사용자
+
0
+
+
+
로그인 성공
+
0
+
+
+
등록 역할
+
0
+
+
+ + + 사용자 관리 + 역할 관리 + 로그인 이력 +
+
+ + diff --git a/src/main/resources/templates/admin/fragments.html b/src/main/resources/templates/admin/fragments.html new file mode 100644 index 0000000..09c43b2 --- /dev/null +++ b/src/main/resources/templates/admin/fragments.html @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/main/resources/templates/admin/login-history.html b/src/main/resources/templates/admin/login-history.html new file mode 100644 index 0000000..06b7e79 --- /dev/null +++ b/src/main/resources/templates/admin/login-history.html @@ -0,0 +1,31 @@ + + +WBX Admin - Login History + +
+
+
+ +
+ + + + + + + + + + + + +
시간이메일액션IP인증방법사유
+ 성공 + 실패 + 로그아웃 +
+
+
+
+ + diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 0000000..380b4ff --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,22 @@ + + + + + WBX Admin - Login + + + + + + diff --git a/src/main/resources/templates/admin/permissions.html b/src/main/resources/templates/admin/permissions.html new file mode 100644 index 0000000..d4222d2 --- /dev/null +++ b/src/main/resources/templates/admin/permissions.html @@ -0,0 +1,41 @@ + + +WBX Admin - Permissions + +
+
+
+ +
+ + + + + + + + + + + + +
역할모듈액션범위관리
+ + + + + + +
+ +
+
등록된 권한이 없습니다. 역할 관리에서 권한을 추가하세요.
+
+
+
+ + diff --git a/src/main/resources/templates/admin/role-detail.html b/src/main/resources/templates/admin/role-detail.html new file mode 100644 index 0000000..e244fa8 --- /dev/null +++ b/src/main/resources/templates/admin/role-detail.html @@ -0,0 +1,89 @@ + + +WBX Admin - Role Detail + +
+
+
+ +
+
+ + +
+

역할 정보

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

권한 추가

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+ + + + + + + + + + + +
모듈액션범위관리
+ + +
+ +
+
설정된 권한이 없습니다. 위 폼에서 권한을 추가하세요.
+
+
+
+ + diff --git a/src/main/resources/templates/admin/roles.html b/src/main/resources/templates/admin/roles.html new file mode 100644 index 0000000..b50e9ae --- /dev/null +++ b/src/main/resources/templates/admin/roles.html @@ -0,0 +1,57 @@ + + +WBX Admin - Roles + +
+
+
+ +
+
+ + +
+

새 역할 추가

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+ + + + + + + + + + + + + +
ID코드이름설명시스템관리
+ 상세/권한 +
+ +
+
등록된 역할이 없습니다.
+
+
+
+ + diff --git a/src/main/resources/templates/admin/system-health.html b/src/main/resources/templates/admin/system-health.html new file mode 100644 index 0000000..7de134a --- /dev/null +++ b/src/main/resources/templates/admin/system-health.html @@ -0,0 +1,23 @@ + + +WBX Admin - System Health + +
+
+
+ +
+ + + + + + + + +
항목
+
+
+
+ + diff --git a/src/main/resources/templates/admin/user-detail.html b/src/main/resources/templates/admin/user-detail.html new file mode 100644 index 0000000..00b0071 --- /dev/null +++ b/src/main/resources/templates/admin/user-detail.html @@ -0,0 +1,154 @@ + + +WBX Admin - User Detail + +
+
+
+ +
+
+ + +
+

기본 정보

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+

계정 상태

+
+
상태
+
+ 활성 + 비활성 +
+
잠금
+
+ 잠금 (실패 회) + 정상 +
+
MFA
+
+
SSO
+
+
최종 로그인
+
+
생성일
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+

역할 할당

+
+
+ + +
+ +
+
+ + + + + + + + + + +
역할 코드역할 이름할당일관리
+ + + + +
+ +
+
+
+

할당된 역할이 없습니다.

+
+ + +
+

로그인 이력 (최근 10건)

+
+ + + + + + + + + + + +
시간액션IP인증방법
+ 성공 + 실패 + 로그아웃 +
이력이 없습니다.
+
+
+
+
+ + diff --git a/src/main/resources/templates/admin/users.html b/src/main/resources/templates/admin/users.html new file mode 100644 index 0000000..96743a2 --- /dev/null +++ b/src/main/resources/templates/admin/users.html @@ -0,0 +1,73 @@ + + +WBX Admin - Users + +
+
+
+ +
+
+ + +
+

새 사용자 추가

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + +
ID이메일사용자명이름관리자상태잠금최종 로그인관리
SA + 활성 + 비활성 + 잠금 + 상세 +
+
+
+
+ + diff --git a/src/test/java/kr/co/accura/wbx/spring/WbxSpringCoreApplicationTests.java b/src/test/java/kr/co/accura/wbx/spring/WbxSpringCoreApplicationTests.java new file mode 100644 index 0000000..71a6e20 --- /dev/null +++ b/src/test/java/kr/co/accura/wbx/spring/WbxSpringCoreApplicationTests.java @@ -0,0 +1,13 @@ +package kr.co.accura.wbx.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class WbxSpringCoreApplicationTests { + + @Test + void contextLoads() { + } + +}