Merge commit '5367465027cfd9d978e6e5f19182b2f38a7ecfb5'
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# CLAUDE.md - alist API 프로젝트
|
||||
|
||||
## 프로젝트 개요
|
||||
- **프로젝트명**: alist API
|
||||
- **그룹**: com.alist
|
||||
- **포트**: 8106
|
||||
- **빌드 결과물**: `api.jar`
|
||||
|
||||
## 기술 스택
|
||||
- **Java**: 21
|
||||
- **Framework**: Spring Boot 3.5.10
|
||||
- **빌드 도구**: Gradle
|
||||
- **DB**: MariaDB
|
||||
- **ORM**: MyBatis (mapper XML: `classpath:mapper/**/*.xml`)
|
||||
- **인증**: JWT (jjwt 0.11.5) + Spring Security
|
||||
- **API 문서**: Swagger (springdoc-openapi 2.6.0)
|
||||
- **기타**: Lombok, Validation, Actuator, log4jdbc
|
||||
|
||||
## 패키지 구조
|
||||
```
|
||||
com.alist.api
|
||||
├── common
|
||||
│ ├── response/ # ApiResponse, ApiResponseCode
|
||||
│ └── utils/ # 공통 유틸리티
|
||||
├── config
|
||||
│ ├── jwt/ # JWT 필터, 핸들러, Provider
|
||||
│ ├── exception/ # GlobalExceptionHandler
|
||||
│ ├── properties/ # JwtProperties 등
|
||||
│ ├── SecurityConfig.java
|
||||
│ └── OpenApiConfig.java
|
||||
└── modules
|
||||
└── {도메인}/ # Controller, Service, Mapper, DTO
|
||||
```
|
||||
|
||||
## 프로파일
|
||||
| 프로파일 | 설명 |
|
||||
|---------|------|
|
||||
| `local` | 로컬 개발 환경 |
|
||||
| `pjt` | 프로젝트(개발) 환경 |
|
||||
|
||||
## 보안 구조
|
||||
- **Swagger**: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory)
|
||||
- **API**: JWT Bearer 토큰 인증 (Stateless)
|
||||
- **공개 경로**: `/`, `/actuator/health`, `/auth/**`, `/api/user/signup`
|
||||
|
||||
## 응답 코드 규칙
|
||||
|
||||
`ApiResponseCode` enum으로 관리. 주요 코드:
|
||||
|
||||
| 코드 | 메시지 | HTTP Status | 용도 |
|
||||
|------|--------|-------------|------|
|
||||
| `CODE_200` | 성공 | 200 OK | 일반 성공 |
|
||||
| `CODE_400` | 잘못된 요청 | 400 Bad Request | 일반 클라이언트 오류 |
|
||||
| `CODE_401` | 인증 필요 합니다. | 401 Unauthorized | 인증 없음 |
|
||||
| `CODE_403` | 접근 권한 필요 합니다. | 403 Forbidden | 권한 없음 |
|
||||
| `CODE_404` | 페이지를 찾을 수 없습니다. | 404 Not Found | 리소스 없음 |
|
||||
| `CODE_405` | 잘못된 요청입니다. 요청 방식을 확인해 주세요. | 405 Method Not Allowed | 메서드 불일치 |
|
||||
| `CODE_500` | 요청을 처리하는 중 오류가 발생했습니다. | 500 Internal Server Error | 서버 오류 |
|
||||
| `CODE_2001` | {0} 정보 조회에 성공하였습니다. | 200 OK | 단건 조회 성공 |
|
||||
| `CODE_2002` | {0} 등록 되었습니다. | 201 Created | 등록 성공 |
|
||||
| `CODE_2003` | 조회된 정보가 없습니다. | 200 OK | 조회 결과 없음 |
|
||||
| `CODE_2004` | 중복된 {0} 정보 입니다. | 409 Conflict | 중복 데이터 |
|
||||
| `CODE_4001` | 입력값을 확인해주세요. | 400 Bad Request | `@Valid` / 바인딩 / 타입오류 / JSON 파싱 실패 |
|
||||
| `CODE_4003` | 필수 요청 파라미터가 누락되었습니다. | 400 Bad Request | 필수 파라미터 누락 |
|
||||
|
||||
- `{0}` 자리에 대상명 삽입 (예: `CODE_2001` → "회원 정보 조회에 성공하였습니다.")
|
||||
|
||||
## 빌드 및 실행
|
||||
```bash
|
||||
# 빌드
|
||||
./gradlew bootJar
|
||||
|
||||
# 로컬 실행
|
||||
./gradlew bootRun --args='--spring.profiles.active=local'
|
||||
|
||||
# JAR 실행
|
||||
java -jar build/libs/api.jar --spring.profiles.active=local
|
||||
```
|
||||
|
||||
## Swagger 접속
|
||||
- URL: `http://localhost:8106/swagger-ui.html`
|
||||
- 인증: `swagger.login.id` / `swagger.login.password` (환경별 yaml에 설정)
|
||||
|
||||
## 코드 작성 규칙
|
||||
- 응답은 `ApiResponse<T>` 래퍼 사용
|
||||
- 응답 코드는 `ApiResponseCode` enum 사용
|
||||
- MyBatis Mapper XML은 `src/main/resources/mapper/` 하위에 작성
|
||||
- 카멜케이스 자동 변환 활성화 (`map-underscore-to-camel-case: true`)
|
||||
- 새 모듈 추가 시: `modules/{moduleName}/` 하위에 Controller, Service, Mapper, dto/, vo/ 구조로 생성
|
||||
- Mapper XML은 `resources/mapper/{moduleName}/` 에 위치
|
||||
|
||||
## MyBatis 규칙
|
||||
- Mapper XML 위치: `src/main/resources/mapper/**/*.xml`
|
||||
- `map-underscore-to-camel-case: true` 설정 → DB 컬럼 `user_idx` → Java 필드 `userIdx` 자동 매핑
|
||||
- Mapper 인터페이스와 XML의 namespace, id 반드시 일치시킬 것
|
||||
- VO: DB 조회 결과 매핑용 / DTO: 서비스 레이어 간 데이터 전달용 / Form: 컨트롤러 입력 검증용
|
||||
|
||||
## CI/CD
|
||||
- **Jenkins**: `Jenkinsfile.pjt`
|
||||
- **Docker**: `Dockerfile`
|
||||
- **배포 스크립트**: `deploy/` 디렉토리
|
||||
|
||||
## 개발 서버 (pjt)
|
||||
|
||||
### 도메인
|
||||
- **API**: `api-alist.pjt.kr`
|
||||
- **파일(업로드)**: `file-alist.pjt.kr`
|
||||
- **Swagger**: `https://api-alist.pjt.kr/swagger-ui/index.html`
|
||||
|
||||
### Docker
|
||||
- **레지스트리**: `registry.pjt.kr`
|
||||
- **이미지**: `registry.pjt.kr/alist/api`
|
||||
- **컨테이너명**: `alist-api`
|
||||
- **포트**: `127.0.0.1:8106->8106/tcp`
|
||||
|
||||
### 서버 디렉토리 (`/srv/project/alist/`)
|
||||
```
|
||||
/srv/project/alist/
|
||||
├── compose/ # docker-compose 파일
|
||||
├── data/ # 데이터
|
||||
├── env/ # 환경변수 파일
|
||||
├── logs/ # 로그
|
||||
├── scripts/ # 배포/운영 스크립트
|
||||
└── uploads/ # 업로드 파일 (file-alist.pjt.kr 루트)
|
||||
```
|
||||
|
||||
### Nginx
|
||||
- `file-alist.pjt.kr` → `/srv/project/alist/uploads` (정적 파일 서빙)
|
||||
- HTTP(80) → HTTPS(301) 리다이렉트
|
||||
- SSL: Let's Encrypt
|
||||
- 직접 접근 차단 (`allow 127.0.0.1; deny all;`)
|
||||
|
||||
### 파일 업로드
|
||||
- 업로드 저장 경로: `/srv/project/alist/uploads/`
|
||||
- 업로드 파일 접근 URL: `https://file-alist.pjt.kr/{파일경로}`
|
||||
+230
-1
@@ -8,12 +8,27 @@ pipeline {
|
||||
|
||||
environment {
|
||||
// ===== Registry =====
|
||||
DUMMY = "true"
|
||||
REGISTRY_HOST = "registry.pjt.kr"
|
||||
IMAGE_REPO = "alist/api"
|
||||
DOCKER_CRED = "registry-pjt"
|
||||
|
||||
// ===== api ssh =====
|
||||
SSH_HOST = "121.160.234.222"
|
||||
SSH_PORT = "2001"
|
||||
SSH_USER = "bigfuntnp"
|
||||
SSH_COMPOSE_DIR = "/srv/project/alist/compose"
|
||||
SSH_COMPOSE_FILE = "api-compose.pjt.yml"
|
||||
|
||||
// ===== jenkins workspace path =====
|
||||
WORK_COMPOSE_DIR = "deploy/pjt/compose"
|
||||
WORK_COMPOSE_FILE = "api-compose.pjt.yml"
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('1) Git check out') {
|
||||
steps {
|
||||
echo "[1/1] Git check out"
|
||||
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
@@ -22,10 +37,224 @@ pipeline {
|
||||
steps {
|
||||
sh '''
|
||||
set -e
|
||||
|
||||
echo "[1/1] Gradle build"
|
||||
|
||||
chmod +x ./gradlew
|
||||
./gradlew clean build -x test
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('3) ImageTag create') {
|
||||
steps {
|
||||
script {
|
||||
|
||||
echo "[1/1] ImageTag create"
|
||||
|
||||
env.SHORT_SHA = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim()
|
||||
echo "SHORT_SHA = ${env.SHORT_SHA}"
|
||||
env.IMAGE_TAG = "${env.BUILD_NUMBER}-${env.SHORT_SHA}"
|
||||
echo "IMAGE_TAG = ${env.IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('4) Docker Build') {
|
||||
steps {
|
||||
sh '''
|
||||
set -e
|
||||
|
||||
echo "[1/1] docker build"
|
||||
|
||||
docker build -t ${REGISTRY_HOST}/${IMAGE_REPO}:${IMAGE_TAG} .
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('5) Docker registry Login & Push') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: DOCKER_CRED,
|
||||
usernameVariable: 'DOCKER_USER',
|
||||
passwordVariable: 'DOCKER_PASS'
|
||||
)]) {
|
||||
sh '''
|
||||
set -e
|
||||
|
||||
echo "[1/2] docker registry login"
|
||||
echo "$DOCKER_PASS" | docker login ${REGISTRY_HOST} -u "$DOCKER_USER" --password-stdin
|
||||
|
||||
echo "[2/2] docker image push"
|
||||
docker push ${REGISTRY_HOST}/${IMAGE_REPO}:${IMAGE_TAG}
|
||||
|
||||
docker logout ${REGISTRY_HOST}
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('6) Jenkins cleanup') {
|
||||
steps {
|
||||
sh '''
|
||||
set -e
|
||||
|
||||
echo "[1/3] Remove pushed image from Jenkins"
|
||||
docker rmi ${REGISTRY_HOST}/${IMAGE_REPO}:${IMAGE_TAG} 2>/dev/null || true
|
||||
|
||||
echo "[2/3] Remove dangling images"
|
||||
docker image prune -f
|
||||
|
||||
echo "[3/3] Remove old build cache (safe-ish)"
|
||||
docker builder prune -f || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('7) Copy compose') {
|
||||
steps {
|
||||
sshagent(['ssh-pjt']) {
|
||||
sh '''
|
||||
set -e
|
||||
|
||||
echo "[1/2] ssh login"
|
||||
|
||||
SRC="${WORK_COMPOSE_DIR}/${WORK_COMPOSE_FILE}"
|
||||
DST="${SSH_USER}@${SSH_HOST}:${SSH_COMPOSE_DIR}/${SSH_COMPOSE_FILE}"
|
||||
|
||||
echo "[2/2] Copy compose $SRC -> $DST"
|
||||
scp -P ${SSH_PORT} -o StrictHostKeyChecking=no "$SRC" "$DST"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('8) Remote docker registry login') {
|
||||
steps {
|
||||
sshagent(['ssh-pjt']) {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: DOCKER_CRED,
|
||||
usernameVariable: 'DOCKER_USER',
|
||||
passwordVariable: 'DOCKER_PASS'
|
||||
)]) {
|
||||
sh '''
|
||||
set -e
|
||||
|
||||
echo "[1/1] docker registry login"
|
||||
|
||||
ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${SSH_USER}@${SSH_HOST} '
|
||||
set -e
|
||||
REGISTRY_HOST='"${REGISTRY_HOST}"'
|
||||
DOCKER_USER='"${DOCKER_USER}"'
|
||||
DOCKER_PASS='"${DOCKER_PASS}"'
|
||||
|
||||
echo "$DOCKER_PASS" | docker login "$REGISTRY_HOST" -u "$DOCKER_USER" --password-stdin
|
||||
'
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('9) Remote docker pull') {
|
||||
steps {
|
||||
sshagent(['ssh-pjt']) {
|
||||
sh '''
|
||||
set -e
|
||||
ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
${SSH_USER}@${SSH_HOST} \
|
||||
'set -e
|
||||
cd '"${SSH_COMPOSE_DIR}"'
|
||||
export IMAGE_TAG='"${IMAGE_TAG}"'
|
||||
|
||||
echo "[1/1] docker pull"
|
||||
docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f '"${SSH_COMPOSE_FILE}"' pull
|
||||
'
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('10) Remote docker down & up') {
|
||||
steps {
|
||||
sshagent(['ssh-pjt']) {
|
||||
sh '''
|
||||
set -e
|
||||
ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
${SSH_USER}@${SSH_HOST} \
|
||||
'set -e
|
||||
cd '"${SSH_COMPOSE_DIR}"'
|
||||
export IMAGE_TAG='"${IMAGE_TAG}"'
|
||||
|
||||
echo "[1/3] docker down (remove orphans)"
|
||||
docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f "'"${SSH_COMPOSE_FILE}"'" down --remove-orphans
|
||||
|
||||
echo "[2/3] docker up"
|
||||
docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f '"${SSH_COMPOSE_FILE}"' up -d
|
||||
|
||||
echo "[3/3] docker ps"
|
||||
docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f '"${SSH_COMPOSE_FILE}"' ps
|
||||
'
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('11) Remote cleanup') {
|
||||
steps {
|
||||
sshagent(['ssh-pjt']) {
|
||||
sh '''
|
||||
set -e
|
||||
ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${SSH_USER}@${SSH_HOST} '
|
||||
set -e
|
||||
|
||||
Repo='"${REGISTRY_HOST}/${IMAGE_REPO}"'
|
||||
|
||||
echo "[1/3] Remove old image"
|
||||
docker images "${Repo}" --format '{{.Repository}}:{{.Tag}}' | grep -v -F -f <(docker ps --format '{{.Image}}') | xargs -r docker rmi
|
||||
|
||||
echo "[2/3] Remove dangling images"
|
||||
docker image prune -f
|
||||
|
||||
echo "[3/3] Remove old build cache (safe-ish)"
|
||||
docker builder prune -f || true
|
||||
'
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('12) registry docker image cleanup') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: DOCKER_CRED,
|
||||
usernameVariable: 'DOCKER_USER',
|
||||
passwordVariable: 'DOCKER_PASS'
|
||||
)]) {
|
||||
sh '''
|
||||
set -e
|
||||
|
||||
echo "[1/2] Remove registry old image tag list"
|
||||
TAGS_TO_DELETE=$(curl -s -u "$DOCKER_USER:$DOCKER_PASS" https://${REGISTRY_HOST}/v2/${IMAGE_REPO}/tags/list | jq -r '.tags[]' | grep -E '^[0-9]+' | sort -t- -k1,1n | head -n -5)
|
||||
|
||||
echo "[2/2] Remove registry old image sha list and delete registry"
|
||||
for TAG in $TAGS_TO_DELETE; do
|
||||
DIGEST=$(curl -s -u "$DOCKER_USER:$DOCKER_PASS" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json" -D - https://${REGISTRY_HOST}/v2/${IMAGE_REPO}/manifests/$TAG -o /dev/null | grep -i Docker-Content-Digest | awk '{print $2}' | tr -d $'\r')
|
||||
|
||||
if [ -z "$DIGEST" ]; then
|
||||
echo "[SKIP] $TAG digest not found"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[DRY] $TAG -> $DIGEST"
|
||||
|
||||
curl -s -u "$DOCKER_USER:$DOCKER_PASS" -X DELETE "https://${REGISTRY_HOST}/v2/${IMAGE_REPO}/manifests/${DIGEST}" -o /dev/null -w "HTTP %{http_code}\n"
|
||||
done
|
||||
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,45 @@ dependencies {
|
||||
|
||||
// Web 기본
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
|
||||
// security
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
// jwt
|
||||
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// Lombok
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
// jdbc
|
||||
implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
|
||||
|
||||
// MariaDB
|
||||
implementation 'org.mariadb.jdbc:mariadb-java-client'
|
||||
|
||||
// swagger
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0'
|
||||
|
||||
// MyBatis (Boot 3 전용)
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
|
||||
|
||||
// validation
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
// health
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
bootJar {
|
||||
archiveFileName = "api.jar"
|
||||
}
|
||||
jar {
|
||||
enabled = false
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
services:
|
||||
app:
|
||||
container_name: alist-api
|
||||
image: registry.pjt.kr/alist/api:${IMAGE_TAG}
|
||||
restart: always
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
ports:
|
||||
- "127.0.0.1:8006:8080"
|
||||
- "127.0.0.1:8106:8106"
|
||||
|
||||
env_file:
|
||||
- /srv/project/alist/env/api/.env
|
||||
|
||||
# 🔴 핵심: 로그 볼륨 연결
|
||||
volumes:
|
||||
- /srv/project/alist/logs/api/app:/logs
|
||||
|
||||
@@ -23,13 +26,12 @@ services:
|
||||
-Duser.timezone=Asia/Seoul
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/actuator/health || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8106/actuator/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# 🟡 보험: 도커 로그 폭주 방지
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.alist.api;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class ApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.alist.api.common.response;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
@Getter
|
||||
public class ApiResponse<T> {
|
||||
private T data; // 실제 데이터
|
||||
private String code; // 결과 코드 (SUCCESS, ERROR_001 등)
|
||||
private String message; // 사용자 메시지
|
||||
|
||||
public ApiResponse(T data, String code, String message) {
|
||||
this.data = data;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public static ApiResponse<Void> body(ApiResponseCode code, Object... args) {
|
||||
return new ApiResponse<>(null, code.code(), code.message(args));
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> body(T data, ApiResponseCode code, Object... args) {
|
||||
return new ApiResponse<>(data, code.code(), code.message(args));
|
||||
}
|
||||
|
||||
public static ResponseEntity<ApiResponse<Void>> entity(ApiResponseCode code, Object... args) {
|
||||
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(code, args));
|
||||
}
|
||||
|
||||
public static <T> ResponseEntity<ApiResponse<T>> entity(T data, ApiResponseCode code, Object... args) {
|
||||
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(data, code, args));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.alist.api.common.response;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.text.MessageFormat;
|
||||
|
||||
public enum ApiResponseCode {
|
||||
// ===== Common =====
|
||||
CODE_200 ("200", "성공", HttpStatus.OK),
|
||||
|
||||
CODE_400 ("400", "잘못된 요청", HttpStatus.BAD_REQUEST),
|
||||
CODE_401 ("401", "인증 필요 합니다.", HttpStatus.UNAUTHORIZED),
|
||||
CODE_403 ("403", "접근 권한 필요 합니다.", HttpStatus.FORBIDDEN),
|
||||
CODE_404 ("404", "페이지를 찾을 수 없습니다. 입력하신 주소가 올바른지 확인해주세요.", HttpStatus.NOT_FOUND),
|
||||
CODE_405 ("405", "잘못된 요청입니다. 요청 방식을 확인해 주세요.", HttpStatus.METHOD_NOT_ALLOWED),
|
||||
CODE_500 ("500", "요청을 처리하는 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
|
||||
|
||||
// ===== Success detail =====
|
||||
CODE_2001("2001", "{0} 정보 조회에 성공하였습니다.", HttpStatus.OK),
|
||||
CODE_2002("2002", "{0} 등록 되었습니다.", HttpStatus.CREATED),
|
||||
CODE_2003("2003", "조회된 정보가 없습니다.", HttpStatus.OK),
|
||||
CODE_2004("2004", "중복된 {0} 정보 입니다.", HttpStatus.CONFLICT),
|
||||
|
||||
// ===== Client input errors =====
|
||||
// @Valid / 바인딩 / 타입미스매치 / JSON 파싱 실패 등은 다 여기로
|
||||
CODE_4001("4001", "입력값을 확인해주세요.", HttpStatus.BAD_REQUEST),
|
||||
|
||||
// 필수 요청 파라미터 누락
|
||||
CODE_4003("4003", "필수 요청 파라미터가 누락되었습니다.", HttpStatus.BAD_REQUEST),
|
||||
;
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
private final HttpStatus httpStatus;
|
||||
|
||||
ApiResponseCode(String code, String message, HttpStatus httpStatus) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
public String code() { return code; }
|
||||
|
||||
public String message() { return message; }
|
||||
|
||||
public String message(Object... args) {
|
||||
return MessageFormat.format(this.message, args);
|
||||
}
|
||||
|
||||
public HttpStatus httpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.alist.api.common.utils;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
public class ApiKeyGenerator {
|
||||
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
private static final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
|
||||
public static String userApiKeyProc() {
|
||||
byte[] randomBytes = new byte[32]; // 256-bit
|
||||
secureRandom.nextBytes(randomBytes);
|
||||
return base64Encoder.encodeToString(randomBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.alist.api.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.info.Info;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
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
|
||||
@OpenAPIDefinition(
|
||||
info = @Info(
|
||||
title = "A*List Api App",
|
||||
version = "v1",
|
||||
description = "A*List Api App 입니다."
|
||||
)
|
||||
)
|
||||
public class OpenApiConfig {
|
||||
private static final String SECURITY_SCHEME_NAME = "bearerAuth";
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes(SECURITY_SCHEME_NAME,
|
||||
new SecurityScheme()
|
||||
.name("Authorization")
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.alist.api.config;
|
||||
|
||||
import com.alist.api.config.jwt.JwtAccessDeniedHandler;
|
||||
import com.alist.api.config.jwt.JwtAuthenticationEntryPoint;
|
||||
import com.alist.api.config.jwt.JwtAuthenticationFilter;
|
||||
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
@Value("${swagger.login.id}")
|
||||
private String swaggerLoginId;
|
||||
|
||||
@Value("${swagger.login.password}")
|
||||
private String swaggerLoginPassword;
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
|
||||
return new JwtAuthenticationFilter(jwtTokenProvider);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.csrf(csrf -> csrf.disable());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
|
||||
.accessDeniedHandler(new JwtAccessDeniedHandler())
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/", "/actuator/health", "/auth/**", "/api/testUser/signup").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService() {
|
||||
UserDetails alist = User.builder()
|
||||
.username(swaggerLoginId)
|
||||
.password(passwordEncoder().encode(swaggerLoginPassword))
|
||||
.roles("ADMIN")
|
||||
.build();
|
||||
|
||||
return new InMemoryUserDetailsManager(alist);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.alist.api.config.exception;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.javassist.NotFoundException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
|
||||
|
||||
log.warn("IllegalArgumentException: {}", e.getMessage());
|
||||
|
||||
return ApiResponse.entity(ApiResponseCode.CODE_400);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMissingParam(MissingServletRequestParameterException e) {
|
||||
|
||||
log.warn("Missing request parameter: {} (type={})", e.getParameterName(), e.getParameterType());
|
||||
|
||||
return ApiResponse.entity(ApiResponseCode.CODE_4003);
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
|
||||
|
||||
log.warn("Method not supported: {} (supported={})", e.getMethod(), e.getSupportedHttpMethods());
|
||||
|
||||
return ApiResponse.entity(ApiResponseCode.CODE_405);
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleNotFound(NotFoundException e) {
|
||||
|
||||
log.warn("Not found: {}", e.getMessage());
|
||||
|
||||
return ApiResponse.entity(ApiResponseCode.CODE_404);
|
||||
}
|
||||
|
||||
// 입력오류
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
|
||||
Map<String, String> fieldErrors = new LinkedHashMap<>();
|
||||
|
||||
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
|
||||
fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
|
||||
}
|
||||
|
||||
log.warn("Validation failed: {}", e.getMessage());
|
||||
|
||||
return ApiResponse.entity(fieldErrors, ApiResponseCode.CODE_4001);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
|
||||
|
||||
log.warn("Type mismatch: name={}, value={}", e.getName(), e.getValue());
|
||||
|
||||
return ApiResponse.entity(ApiResponseCode.CODE_4001);
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleNotReadable(HttpMessageNotReadableException e) {
|
||||
|
||||
log.warn("Unreadable message (json parse?) : {}", e.getMessage());
|
||||
|
||||
return ApiResponse.entity(ApiResponseCode.CODE_4001);
|
||||
}
|
||||
|
||||
@ExceptionHandler(BindException.class)
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> handleBindException(BindException e) {
|
||||
Map<String, String> fieldErrors = new LinkedHashMap<>();
|
||||
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
|
||||
fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
|
||||
}
|
||||
log.warn("Bind failed: {}", e.getMessage());
|
||||
|
||||
return ApiResponse.entity(fieldErrors, ApiResponseCode.CODE_4001);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
||||
|
||||
log.error("Unhandled exception occurred", e);
|
||||
|
||||
return ApiResponse.entity(ApiResponseCode.CODE_500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.alist.api.config.jwt;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AccessDeniedException e) throws IOException {
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
|
||||
ApiResponse<Void> body = ApiResponse.body(ApiResponseCode.CODE_403);
|
||||
objectMapper.writeValue(response.getOutputStream(), body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.alist.api.config.jwt;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
AuthenticationException e) throws IOException {
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
|
||||
ApiResponse<Void> body = ApiResponse.body(ApiResponseCode.CODE_401);
|
||||
objectMapper.writeValue(response.getOutputStream(), body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.alist.api.config.jwt;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
String token = resolveToken(request);
|
||||
|
||||
if (token != null && jwtTokenProvider.validateToken(token)) {
|
||||
String userId = jwtTokenProvider.getUserId(token);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
|
||||
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
SecurityContextHolder.clearContext();
|
||||
// 로깅은 여기서 해도 됨 (SLF4J)
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (bearer == null) return null;
|
||||
|
||||
// "Bearer " 뒤 토큰만 추출
|
||||
if (bearer.startsWith("Bearer ")) {
|
||||
String token = bearer.substring(7).trim();
|
||||
return token.isEmpty() ? null : token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.alist.api.config.jwt;
|
||||
|
||||
import com.alist.api.config.properties.JwtProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
private final JwtProperties jwtProperties;
|
||||
private final SecretKey secretKey;
|
||||
|
||||
public JwtTokenProvider(JwtProperties jwtProperties) {
|
||||
this.jwtProperties = jwtProperties;
|
||||
this.secretKey = Keys.hmacShaKeyFor(
|
||||
jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
}
|
||||
|
||||
/** 토큰생성 **/
|
||||
public String createToken(String userId) {
|
||||
Date now = new Date();
|
||||
Date expiry = new Date(
|
||||
now.getTime() + jwtProperties.getAccessTokenValiditySeconds() * 1000
|
||||
);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(userId)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiry)
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/* 엑세스 토큰 생성 */
|
||||
public String createAccessToken(long userTokenIdx, String role) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userTokenIdx))
|
||||
.claim("role", role)
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/* 리프레시 토큰 생성 */
|
||||
public String createRefreshToken(long userTokenIdx) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userTokenIdx))
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(expiry))
|
||||
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** 토큰에서 subject 추출 */
|
||||
public String getUserId(String token) {
|
||||
return parseClaims(token).getSubject();
|
||||
}
|
||||
|
||||
/** 토큰 검증 */
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseClaims(token);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Claims parseClaims(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.alist.api.config.properties;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties(prefix = "jwt")
|
||||
public class JwtProperties {
|
||||
String secret;
|
||||
long accessTokenValiditySeconds;
|
||||
long refreshTokenValiditySeconds;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.alist.api.modules.auth;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||
import com.alist.api.modules.auth.form.TokenForm;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public AuthController(JwtTokenProvider jwtTokenProvider) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@PostMapping("/token")
|
||||
public ResponseEntity<ApiResponse<String>> token(@Valid @RequestBody TokenForm tokenForm) {
|
||||
String accessToken = jwtTokenProvider.createToken(tokenForm.getUserId());
|
||||
|
||||
return ApiResponse.entity(accessToken, ApiResponseCode.CODE_2001, "엑세스 토큰");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.alist.api.modules.auth.form;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "토큰 요청 폼")
|
||||
public class TokenForm {
|
||||
|
||||
@NotBlank(message = "사용자명은 필수입니다.")
|
||||
@Schema(description = "사용자명", example = "user123")
|
||||
private String userId;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.alist.api.modules.main;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class MainController {
|
||||
|
||||
@GetMapping(value = "/")
|
||||
public String index() {
|
||||
return "redirect:/swagger-ui/index.html";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.alist.api.modules.testUser;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
import com.alist.api.modules.testUser.dto.TestUserDto;
|
||||
import com.alist.api.modules.testUser.form.TestUserSignupForm;
|
||||
import com.alist.api.modules.testUser.service.TestUserService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/testUser")
|
||||
public class TestUserController {
|
||||
|
||||
public final TestUserService testUserService;
|
||||
|
||||
public TestUserController(TestUserService testUserService) {
|
||||
this.testUserService = testUserService;
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public ResponseEntity<ApiResponse<TestUserDto>> signup(@Valid @RequestBody TestUserSignupForm testUserSignupForm) {
|
||||
|
||||
TestUserDto userDto = testUserService.insertTestUserProc(testUserSignupForm.testUserDto());
|
||||
|
||||
if (userDto.getResultCode() == 2004) {
|
||||
return ApiResponse.entity(userDto, ApiResponseCode.CODE_2004, "아이디");
|
||||
}
|
||||
|
||||
return ApiResponse.entity(userDto, ApiResponseCode.CODE_2002, "아이디");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.alist.api.modules.testUser.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Setter
|
||||
@Getter
|
||||
public class TestUserDto {
|
||||
private Integer userIdx;
|
||||
private String id;
|
||||
|
||||
@JsonIgnore
|
||||
private String newPassword;
|
||||
@JsonIgnore
|
||||
private String password;
|
||||
@JsonIgnore
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.alist.api.modules.testUser.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Setter
|
||||
@Getter
|
||||
public class TestUserTokenDto {
|
||||
private int userTokenIdx;
|
||||
private int userIdx;
|
||||
private String userApiKey;
|
||||
private String userRole;
|
||||
private int resultCode;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.alist.api.modules.testUser.form;
|
||||
|
||||
import com.alist.api.modules.testUser.dto.TestUserDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Setter
|
||||
@Getter
|
||||
@Schema(description = "회원 가입 요청 폼")
|
||||
public class TestUserSignupForm {
|
||||
@Schema(
|
||||
description = "사용자 아이디 (공백 불가)",
|
||||
example = "test"
|
||||
)
|
||||
@NotBlank(message = "아이디를 입력해주세요.")
|
||||
private String id;
|
||||
|
||||
@Schema(
|
||||
description = "비밀번호 (8~64자, 영문 + 숫자 조합, 공백 불가)",
|
||||
example = "pass1234"
|
||||
)
|
||||
@NotBlank(message = "비밀번호를 입력해주세요.")
|
||||
@Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다.")
|
||||
@Pattern(
|
||||
regexp = "^(?=.*[A-Za-z])(?=.*\\d)\\S+$",
|
||||
message = "비밀번호는 영문과 숫자를 포함하고 공백이 없어야 합니다."
|
||||
)
|
||||
private String password;
|
||||
|
||||
public TestUserDto testUserDto() {
|
||||
TestUserDto testUserDto = new TestUserDto();
|
||||
testUserDto.setId(id.trim());
|
||||
testUserDto.setPassword(password);
|
||||
return testUserDto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.alist.api.modules.testUser.mapper;
|
||||
|
||||
import com.alist.api.modules.testUser.dto.TestUserDto;
|
||||
import com.alist.api.modules.testUser.dto.TestUserTokenDto;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface TestUserMapper {
|
||||
int selectDuplicateTestUserCount(TestUserDto testUserDto);
|
||||
|
||||
int insertTestUserSignup(TestUserDto testUserDto);
|
||||
|
||||
int insertTestUserTokenSignup(TestUserTokenDto testUserTokenDto);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.alist.api.modules.testUser.service;
|
||||
|
||||
import com.alist.api.common.utils.ApiKeyGenerator;
|
||||
import com.alist.api.modules.testUser.dto.TestUserDto;
|
||||
import com.alist.api.modules.testUser.dto.TestUserTokenDto;
|
||||
import com.alist.api.modules.testUser.mapper.TestUserMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class TestUserService {
|
||||
private final TestUserMapper testUserMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public TestUserService(TestUserMapper testUserMapper, PasswordEncoder passwordEncoder) {
|
||||
this.testUserMapper = testUserMapper;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TestUserDto insertTestUserProc(TestUserDto testUserDto) {
|
||||
// 중복체크
|
||||
if (testUserMapper.selectDuplicateTestUserCount(testUserDto) > 0) {
|
||||
testUserDto.setResultCode(2004);
|
||||
return testUserDto;
|
||||
}
|
||||
|
||||
// 비밀번호 생성
|
||||
testUserDto.setNewPassword(passwordEncoder.encode(testUserDto.getPassword()));
|
||||
|
||||
// 회원정보 입력
|
||||
int result = testUserMapper.insertTestUserSignup(testUserDto);
|
||||
|
||||
if (result > 0) {
|
||||
// user_api_key 생성
|
||||
TestUserTokenDto testUserTokenDto = new TestUserTokenDto();
|
||||
testUserTokenDto.setUserIdx(testUserDto.getUserIdx());
|
||||
|
||||
apiKeyWithRetry(testUserTokenDto);
|
||||
}
|
||||
|
||||
log.info("cnt : " + testUserDto.getUserIdx());
|
||||
log.info("userIdx : " + testUserDto.getUserIdx());
|
||||
|
||||
return testUserDto;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int apiKeyWithRetry(TestUserTokenDto testUserTokenDto) {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
testUserTokenDto.setUserApiKey(ApiKeyGenerator.userApiKeyProc());
|
||||
try {
|
||||
return testUserMapper.insertTestUserTokenSignup(testUserTokenDto);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// 충돌이면 다시 생성
|
||||
log.warn("Duplicate API key. retrying... userId={}", testUserTokenDto.getUserIdx());
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("API key generation failed after retries.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.alist.api.modules.testUser.vo;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class TestUserVo {
|
||||
private String id;
|
||||
}
|
||||
@@ -1,7 +1,27 @@
|
||||
server:
|
||||
port: 8006
|
||||
spring:
|
||||
application:
|
||||
name: api
|
||||
datasource:
|
||||
url: jdbc:log4jdbc:mariadb://121.160.234.222:3000/ALISTLMS?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul&connectionCollation=utf8mb4_uca1400_ai_ci
|
||||
username: alist_dev
|
||||
password: 1qaz2wsx!@
|
||||
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||
|
||||
logging:
|
||||
config: classpath:logback-local.xml
|
||||
|
||||
jwt:
|
||||
secret: "F9k3s!29dkF#1lP0X9QZx8eW!2m@0AbD"
|
||||
access-token-validity-seconds: 3600
|
||||
refresh-token-validity-seconds: 2592000
|
||||
|
||||
swagger:
|
||||
login:
|
||||
id: alist
|
||||
password: "1qaz2wsx!@"
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
@@ -1,5 +1,29 @@
|
||||
spring:
|
||||
application:
|
||||
name: api
|
||||
main:
|
||||
banner-mode: log
|
||||
datasource:
|
||||
url: jdbc:log4jdbc:mariadb://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul&connectionCollation=utf8mb4_uca1400_ai_ci
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||
|
||||
logging:
|
||||
config: classpath:logback-pjt.xml
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
access-token-validity-seconds: 3600
|
||||
refresh-token-validity-seconds: 2592000
|
||||
|
||||
swagger:
|
||||
login:
|
||||
id: ${SWAGGER_ID}
|
||||
password: ${SWAGGER_PASSWORD}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
@@ -1,3 +1,29 @@
|
||||
server:
|
||||
port: 8106
|
||||
forward-headers-strategy: framework
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: api
|
||||
|
||||
mybatis:
|
||||
mapper-locations: classpath:mapper/**/*.xml
|
||||
type-aliases-package: com.alist.api
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
@@ -0,0 +1,3 @@
|
||||
log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
|
||||
log4jdbc.drivers=org.mariadb.jdbc.Driver
|
||||
log4jdbc.dump.sql.maxlinelength=0
|
||||
@@ -4,19 +4,39 @@
|
||||
<!-- 로그 디렉토리 -->
|
||||
<property name="LOG_PATH" value="/logs" />
|
||||
|
||||
<!-- Spring Boot가 제공하는 %clr / 색상 변환기 등록 -->
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
|
||||
<!-- 콘솔 -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<pattern>%clr(%d{HH:mm:ss.SSS}){faint} %clr(%5p) %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 콘솔 appender: Boot 기본 제공 -->
|
||||
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
|
||||
|
||||
<!-- ==== 로컬(local) ==== -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<!-- 우리 코드 -->
|
||||
<!-- springframework -->
|
||||
<logger name="org.springframework" level="INFO"/>
|
||||
|
||||
<!-- project -->
|
||||
<logger name="com.alist.api" level="DEBUG"/>
|
||||
|
||||
<!-- MyBatis SQL/파라미터 보고 싶으면 DEBUG -->
|
||||
<logger name="org.mybatis" level="DEBUG"/>
|
||||
<logger name="org.apache.ibatis" level="DEBUG"/>
|
||||
|
||||
<!-- log4jdbc -->
|
||||
<logger name="jdbc.sqltiming" level="DEBUG"/>
|
||||
<logger name="jdbc.resultsettable" level="DEBUG"/>
|
||||
<logger name="jdbc.audit" level="OFF"/>
|
||||
<logger name="jdbc.resultset" level="OFF"/>
|
||||
<logger name="jdbc.sqlonly" level="OFF"/>
|
||||
|
||||
</configuration>
|
||||
|
||||
@@ -4,28 +4,20 @@
|
||||
<!-- 로그 디렉토리 -->
|
||||
<property name="LOG_PATH" value="/logs" />
|
||||
|
||||
<!-- 콘솔 -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 파일 (운영에서 사용) -->
|
||||
<!-- 파일 -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
|
||||
<!-- 현재 로그는 고정 위치 (tail 편함) -->
|
||||
<file>${LOG_PATH}/app.log</file>
|
||||
<file>${LOG_PATH}/api.log</file>
|
||||
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
|
||||
<!-- 롤링 파일은 날짜 폴더(yyyy/MM) 하위로 저장 -->
|
||||
<fileNamePattern>
|
||||
${LOG_PATH}/%d{yyyy/MM}/app.%d{yyyy-MM-dd}.%i.log.gz
|
||||
${LOG_PATH}/%d{yyyy/MM}/api.log-%d{yyyy-MM-dd}.%i
|
||||
</fileNamePattern>
|
||||
|
||||
<timeBasedFileNamingAndTriggeringPolicy
|
||||
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
|
||||
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
|
||||
<maxFileSize>50MB</maxFileSize>
|
||||
</timeBasedFileNamingAndTriggeringPolicy>
|
||||
|
||||
@@ -34,7 +26,7 @@
|
||||
</rollingPolicy>
|
||||
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger - %msg%n</pattern>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} | %-5level | %-15thread | %-40logger{40} | %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
@@ -44,7 +36,21 @@
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
|
||||
<!-- 우리 코드 -->
|
||||
<!-- springframework -->
|
||||
<logger name="org.springframework" level="WARN"/>
|
||||
|
||||
<!-- project -->
|
||||
<logger name="com.alist.api" level="INFO"/>
|
||||
|
||||
<!-- MyBatis -->
|
||||
<logger name="org.mybatis" level="WARN"/>
|
||||
<logger name="org.apache.ibatis" level="WARN"/>
|
||||
|
||||
<!-- log4jdbc -->
|
||||
<logger name="jdbc.sqltiming" level="INFO"/>
|
||||
<logger name="jdbc.resultsettable" level="OFF"/>
|
||||
<logger name="jdbc.audit" level="OFF"/>
|
||||
<logger name="jdbc.resultset" level="OFF"/>
|
||||
<logger name="jdbc.sqlonly" level="OFF"/>
|
||||
|
||||
</configuration>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="com.alist.api.modules.testUser.mapper.TestUserMapper">
|
||||
<insert id="insertTestUserSignup" useGeneratedKeys="true" keyProperty="userIdx">
|
||||
/*UserSignupMapper.insertTestUserSignup*/
|
||||
insert into test_user (id, password, del_yn, create_at, update_at)
|
||||
value (#{id}, #{newPassword}, 1, now(), now())
|
||||
</insert>
|
||||
<insert id="insertTestUserTokenSignup" useGeneratedKeys="true" keyProperty="userTokenIdx">
|
||||
/*UserSignupMapper.insertTestUserTokenSignup*/
|
||||
insert into test_user_token (user_idx, user_api_key, user_role, created_at, updated_at)
|
||||
value (#{userIdx}, #{userApiKey}, 'user', now(), now())
|
||||
</insert>
|
||||
|
||||
<select id="selectDuplicateTestUserCount" resultType="int">
|
||||
/*UserSignupMapper.selectDuplicateTestUserCount*/
|
||||
select count(*)
|
||||
from test_user
|
||||
where id = #{id}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -2,8 +2,10 @@ package com.alist.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("local")
|
||||
class ApiApplicationTests {
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user