Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f389d652d6 | |||
| 23efdf4b9a | |||
| b9d534ac7e | |||
| 1ebd75dad1 | |||
| 9da2585f29 | |||
| 07683e843d | |||
| 6e58d57821 | |||
| 8c78f5b192 | |||
| 64c5d87a99 | |||
| a121c7710e | |||
| 67667724ee | |||
| 2a5d7cbc64 | |||
| 940d9002ea | |||
| bb8f6d27f1 | |||
| 961ea80060 | |||
| b52a4eda67 | |||
| 2103183bf5 | |||
| 290c25aa21 | |||
| 7395ebfc79 | |||
| 999f36a2b3 | |||
| 63a6bc82c8 | |||
| 2d1bc74099 | |||
| cf3fa4845f | |||
| 48f59d7d62 | |||
| 25bad9c5fc | |||
| 6733aafd79 | |||
| 2275c857e9 | |||
| bc5c44b4d7 | |||
| d8ab8bc4e1 | |||
| 673635ffeb | |||
| 47a5e85789 | |||
| 8ae793626b | |||
| 5311c816d2 | |||
| c57b80b950 | |||
| a40e2b5161 | |||
| 82cd4049af | |||
| 70e0b951c4 | |||
| bb308493d5 | |||
| d021c736ad | |||
| 0a6228d66b | |||
| 5a2ef8f754 | |||
| 68bcd449fe | |||
| 309f44a011 | |||
| 35f3f9d679 | |||
| 4360298934 | |||
| be9b62d05e | |||
| 407f9e4cb2 | |||
| 93446e5b41 | |||
| f2fe4f10c1 | |||
| 0cf29d4f52 | |||
| 719b25ce06 | |||
| 1cda5257ef | |||
| 0651014274 | |||
| 2d2747f4b9 | |||
| bb95da21cf | |||
| 2e08d813d9 | |||
| 8c6347a5e0 | |||
| 41ffc865e8 | |||
| 752bc008aa | |||
| c71c86792c | |||
| 4a8a3a9d6d | |||
| 36cc65add9 | |||
| 4032dbdd41 | |||
| 63ae5ed6c9 | |||
| 2799a61380 | |||
| 43d3f8947e | |||
| 936c26c787 | |||
| 9a03ff19d1 | |||
| 41b7eba619 | |||
| b1d4b6a60e | |||
| db245fa0f8 | |||
| 5367465027 | |||
| d8239f635e | |||
| f293406661 | |||
| 6e734f2b92 | |||
| b59e6d54e4 | |||
| 7067238dfd | |||
| bfa5166941 | |||
| cf89281616 | |||
| aa6bb2b579 | |||
| 5a59c78d83 | |||
| 9bc499ab97 | |||
| d40ec9488b | |||
| 98da8b9c30 | |||
| 454ddeb95a | |||
| 5b905bb520 | |||
| 633be0a143 | |||
| 61eb97215e | |||
| cfd6ec852c | |||
| c3d65c8874 | |||
| b5bf7d795c | |||
| 7385627622 | |||
| 64382f6741 | |||
| 355441c322 | |||
| af4b2d40a3 | |||
| a956a94cbf | |||
| 567d658773 | |||
| b952a8d841 | |||
| 68cb556370 | |||
| 77454edd41 | |||
| 87f382aaec | |||
| 494f18c451 | |||
| bbedb7a46d | |||
| d28f9eca26 | |||
| 203d65ac36 | |||
| 92d18791d6 | |||
| 1967654d4a | |||
| 18863643ea | |||
| 1f9399837b | |||
| 06faa99267 | |||
| 8d38ae1a9c | |||
| 6722d774a7 | |||
| 0e8876e66f | |||
| 9c85d89f07 | |||
| 5618d54a7c | |||
| a3be7202a3 | |||
| 0add5e1cd8 | |||
| 4d4c998770 | |||
| e4f353b097 | |||
| 1cdf8977ae | |||
| 02b13d18fe | |||
| 494ba05690 | |||
| fd432ff0dd | |||
| cbf40f3099 | |||
| a5ec049040 | |||
| 1113355c74 | |||
| 18eb159f26 | |||
| f87828b4e5 | |||
| 6de8ebca9b | |||
| 7341f28f9d | |||
| 2a2193fdcd | |||
| c1b4f4a6fd | |||
| e0f731718e | |||
| 81ef72f843 | |||
| 8649829e51 | |||
| 38813d8ef6 | |||
| a69293fb51 | |||
| b57bc4aaf4 | |||
| a6dfbb93a5 | |||
| 72e4fff956 | |||
| 75d1dc0dff | |||
| cbc0d644be | |||
| bac18a72f5 |
@@ -0,0 +1,3 @@
|
|||||||
|
/gradlew text eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.jar binary
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
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/
|
||||||
|
|
||||||
|
### Claude Code ###
|
||||||
|
.claude/
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
- 이 저장소에서 작업하는 사람과 코딩 에이전트가 같은 기준으로 개발하도록 돕는 운영 가이드다.
|
||||||
|
- 불필요한 구조 변경보다 작은 단위의 안전한 수정, 빠른 검증, 명확한 보고를 우선한다.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
- 프로젝트 유형: Gradle 기반 Spring Boot 애플리케이션
|
||||||
|
- Java 버전: 21
|
||||||
|
- Spring Boot 버전: 3.5.10
|
||||||
|
- 기본 애플리케이션 이름: `api`
|
||||||
|
- 기본 포트: `8106`
|
||||||
|
- 실행 진입점: `src/main/java/com/alist/api/ApiApplication.java`
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
- **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.8.0)
|
||||||
|
- **기타**: Lombok, Validation, Actuator, log4jdbc
|
||||||
|
|
||||||
|
## 디렉터리 가이드
|
||||||
|
- `src/main/java/com/alist/api`: 애플리케이션 시작점과 업무 코드를 둔다.
|
||||||
|
- `src/main/java/com/alist/api/modules`: 기능별 모듈 패키지를 둔다.
|
||||||
|
- `src/main/resources`: 설정 파일과 로깅 설정을 관리한다.
|
||||||
|
- `deploy`: 배포 관련 리소스가 있으면 이 경로를 우선 확인한다.
|
||||||
|
|
||||||
|
## 현재 확인된 구조
|
||||||
|
- 현재 기준 메인 흐름은 `Controller -> Form -> Dto -> Service -> Mapper(XML) -> vo -> Service -> Controller` 순서로 연결된다.
|
||||||
|
- API 에서 request 받을 때 POST 는 주로 JSON을 사용한다. Controller 는 `form` 객체로 요청을 받은 뒤 DTO 로 변환해서 Service 에 전달한다.
|
||||||
|
- MyBatis는 인터페이스와 XML을 함께 사용한다.
|
||||||
|
- Mapper 인터페이스는 `src/main/java/.../mapper`, SQL XML은 `src/main/resources/mapper/...` 경로를 짝으로 맞춘다.
|
||||||
|
- 공통 응답은 `common/response`, 보안은 `config/security, jwt`, 전역 예외 처리는 `config/exception` 아래에 둔다.
|
||||||
|
- 모듈 패키지는 현재 `auth`, `file`, `main`, `user` 형태로 구성되어 있고, 필요한 모듈만 `dto`, `form`, `mapper`, `service`, `vo`를 둔다.
|
||||||
|
|
||||||
|
## 파일 생성 규칙
|
||||||
|
- 새 기능은 가능하면 `modules/{도메인명}Controller` 단위로 패키지를 만들고 그 아래에 `service`, `mapper`, `dto`, `vo`를 필요한 만큼만 추가한다.
|
||||||
|
- 요청 검증이나 JSON 바인딩이 필요하면 `form` 패키지를 함께 만든다.
|
||||||
|
- 클래스명은 역할이 바로 드러나게 `도메인명 + 역할` 형식을 유지한다. 예: `ApiService`, `ApiMapper`, `ApiVo`
|
||||||
|
- Mapper 인터페이스를 추가하면 같은 이름의 XML을 `src/main/resources/mapper/{도메인경로}` 아래 함께 만든다.
|
||||||
|
- 단순 예시 코드와 운영 코드는 섞지 말고 패키지로 분리한다.
|
||||||
|
- 설정성 클래스는 `config` 하위 역할별 패키지에 둔다. 예: `config.jwt`, `config.exception`, `config.properties`
|
||||||
|
|
||||||
|
## DTO/VO/Mapper 규칙
|
||||||
|
- `Dto`는 저장, 수정, 로그 적재처럼 내부 상태 변경이나 DB update/insert에 사용하는 값 객체로 본다.
|
||||||
|
- `Vo`는 조회 결과나 스케줄 실행 판단에 필요한 읽기 전용 성격의 값으로 본다.
|
||||||
|
- `Form`은 Controller 입력 검증과 요청 바인딩 전용으로 두고, `@Valid` 와 Jakarta Validation 어노테이션을 우선 사용한다.
|
||||||
|
- 현재 코드처럼 DTO/VO는 Lombok `@Getter`, 필요한 경우에만 `@Setter`를 사용한다.
|
||||||
|
- Form 안에는 DTO 변환 메서드를 둘 수 있다. 예: `userDto()`, `fileUploadDto()`
|
||||||
|
- DTO 안에 연관된 다른 DTO 변환이 꼭 필요할 때만 최소한의 보조 메서드를 둔다.
|
||||||
|
- 외부 응답에 노출되면 안 되는 내부 필드는 DTO 에 `@JsonIgnore`로 숨긴다.
|
||||||
|
- Mapper 메서드명은 SQL 동작이 드러나도록 `select`, `insert`, `update` 접두어를 사용한다.
|
||||||
|
- 삭제가 물리 삭제가 아니라 상태 변경이면 `delete` 대신 목적이 드러나는 `update...Canceled`, `update...DelYn` 같은 이름을 우선한다.
|
||||||
|
- Mapper XML `namespace`는 인터페이스의 전체 경로와 정확히 일치시킨다.
|
||||||
|
- XML의 `id`는 Mapper 메서드명과 동일하게 맞춘다.
|
||||||
|
- 조회 결과 타입은 `resultType`, 저장/수정 파라미터는 DTO/VO 필드명과 매핑되는 프로퍼티명을 그대로 사용한다.
|
||||||
|
- Mapper XML 안 SQL 블록 시작부 주석은 현재 코드처럼 `/*Mapper.method*/` 형식을 유지한다.
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- 기존 구조와 네이밍을 우선 존중한다.
|
||||||
|
- 한 번에 큰 리팩터링을 하지 말고, 요청 범위 안에서 필요한 만큼만 수정한다.
|
||||||
|
- 인코딩 문제가 보이는 문자열은 무심코 대량 수정하지 말고 원인과 영향 범위를 먼저 확인한다.
|
||||||
|
- 설정 파일 수정 시 `local`, `pjt`, 공통 설정 간 차이를 함께 확인한다.
|
||||||
|
- 스케줄러 코드는 실행 주기, 중복 실행 가능성, 로그량을 반드시 점검한다.
|
||||||
|
- 인증 방식이 섞여 있으므로 세션 기반 처리와 JWT `SecurityContext` 사용 위치를 먼저 구분하고 수정한다.
|
||||||
|
- 파일 경로를 다루는 기능은 상대경로 탈출, 루트 이탈 방지 같은 검증을 같이 본다.
|
||||||
|
|
||||||
|
## 코드 스타일
|
||||||
|
- Java 코드는 현재 프로젝트 스타일에 맞춰 탭/들여쓰기와 import 정렬을 유지한다.
|
||||||
|
- Lombok은 반복 보일러플레이트 제거에만 절제해서 사용한다.
|
||||||
|
- 로그는 `Slf4j`를 사용하고 반복문 내부 대량 출력은 지양한다.
|
||||||
|
- 새로운 기능은 가능하면 역할이 드러나는 패키지로 분리한다.
|
||||||
|
- 생성자 주입을 기본으로 하고 필드 주입은 추가하지 않는다.
|
||||||
|
- Service 에서 DB 상태를 바꾸는 메서드는 필요한 범위에서 `@Transactional`을 사용하고, 조회 전용은 `readOnly = true`를 우선 검토한다.
|
||||||
|
- 문자열 입력값은 현재 코드처럼 필요한 지점에서 `trim()` 처리하고, null 가능성 여부를 먼저 확인한다.
|
||||||
|
|
||||||
|
## Controller/Service 규칙
|
||||||
|
- Controller 는 요청/응답 조립과 인증 주체 확인에 집중하고, DB 처리나 복잡한 계산은 Service 로 넘긴다.
|
||||||
|
- Controller 응답은 `ResponseEntity<ApiResponse<T>>` 를 기본으로 사용한다. 파일 다운로드처럼 바이너리 응답이 필요한 경우만 예외로 둔다.
|
||||||
|
- Controller 에서는 `@RequestBody`, `@PathVariable`, `@RequestHeader`, `@CookieValue` 를 명시적으로 선언해 요청 출처를 드러낸다.
|
||||||
|
- Service 는 resultCode 를 DTO 에 담아 반환하는 패턴이 일부 있으므로, 기존 모듈 흐름에 맞춰 유지한다.
|
||||||
|
- 인증 사용자 확인은 모듈 구현에 따라 `HttpSession` 또는 `SecurityContextHolder` 를 사용하므로 기존 방식을 먼저 맞춘다.
|
||||||
|
|
||||||
|
## 예외/응답 규칙
|
||||||
|
- 공통 예외 응답은 `GlobalExceptionHandler` 에서 처리하므로 Controller 별 개별 예외 처리를 중복해서 늘리지 않는다.
|
||||||
|
- `@Valid`, 바인딩 실패, JSON 파싱 실패, 타입 오류는 `CODE_4001` 로 통일하고 필드 오류가 있으면 `Map<String, String>` 형태로 반환한다.
|
||||||
|
- 단순 성공/실패 문자열을 직접 내려주기보다 `ApiResponse.entity(...)` 와 `ApiResponseCode` 조합을 우선 사용한다.
|
||||||
|
- 404/405/500 같은 공통 HTTP 오류도 가능하면 `ApiResponseCode` enum 으로 맞춘다.
|
||||||
|
|
||||||
|
## 설정 규칙
|
||||||
|
- 공통 설정은 `application.yaml`, 환경별 차이는 `application-local.yaml`, `application-pjt.yaml` 에 둔다.
|
||||||
|
- `pjt` 프로파일은 DB/Redis/JWT/Swagger 계정을 환경변수 치환으로 받으므로 새 민감정보는 하드코딩하지 않는다.
|
||||||
|
- 로깅 설정은 프로파일별 `logback-local.xml`, `logback-pjt.xml` 을 사용하므로 로그 정책 변경 시 함께 본다.
|
||||||
|
- MyBatis 설정은 `application.yaml` 기준으로 관리하므로 mapper location, alias package, camel case 옵션을 중복 정의하지 않는다.
|
||||||
|
- Redis 는 세션 저장소와 업로드 상태 캐시 용도를 함께 가지므로 키 prefix 충돌 여부를 확인한다.
|
||||||
|
|
||||||
|
## 실행 및 검증
|
||||||
|
- 로컬 실행: `./gradlew bootRun`
|
||||||
|
- 테스트 실행: `./gradlew test`
|
||||||
|
- jar 생성: `./gradlew bootJar`
|
||||||
|
- Windows 명령: `.\gradlew.bat bootRun`, `.\gradlew.bat test`, `.\gradlew.bat bootJar`
|
||||||
|
- 현재 테스트 코드는 최소 수준이므로 기능 수정 시 단위 테스트 또는 최소 통합 검증 범위를 직접 보강하는 쪽을 우선한다.
|
||||||
|
|
||||||
|
## 변경 시 체크리스트
|
||||||
|
- 변경한 코드와 직접 관련된 파일만 수정했는지 확인한다.
|
||||||
|
- 새 API, 스케줄러, 설정 추가 시 관련 설정 파일과 테스트를 함께 검토한다.
|
||||||
|
- 로그 레벨과 로그량이 운영 환경에서 감당 가능한지 확인한다.
|
||||||
|
- 기동 실패 가능성이 있는 설정 변경은 실행 또는 테스트로 검증한다.
|
||||||
|
- Mapper 인터페이스 추가/변경 시 XML namespace, id, parameter/result 매핑이 같이 맞는지 확인한다.
|
||||||
|
- 공개 경로나 권한 정책을 바꿨다면 SecurityConfig 와 Swagger 노출 범위를 같이 확인한다.
|
||||||
|
- 파일 업로드/다운로드 기능 수정 시 DB 상태, Redis 상태, 실제 파일 시스템 경로가 같이 맞는지 확인한다.
|
||||||
|
|
||||||
|
## 에이전트 응답 원칙
|
||||||
|
- 무엇을 바꿨는지보다 왜 그렇게 바꿨는지를 짧고 분명하게 설명한다.
|
||||||
|
- 파일 수정 후 가능하면 테스트 또는 최소 실행 검증 결과를 함께 남긴다.
|
||||||
|
- 검증하지 못한 내용은 추정으로 말하지 않고 미실행 사유를 적는다.
|
||||||
|
- 요청 범위를 벗어나는 개선점은 강제로 반영하지 말고 제안으로 분리한다.
|
||||||
|
|
||||||
|
## 사용자 선호 규칙
|
||||||
|
- 소스코드 변경이 필요한 요청에서는 바로 구현하지 말고 먼저 다음 중 무엇을 원하는지 확인한다.
|
||||||
|
- `코드로 보여주기`: 사용자가 직접 프로젝트에 반영할 수 있도록 예시 코드나 패치를 제공한다.
|
||||||
|
- `직접 작성하기`: 에이전트가 저장소에 직접 수정한다.
|
||||||
|
- 사용자는 코드 흐름을 먼저 파악하고 코드 컨벤션에 맞게 직접 반영하는 방식을 선호하므로, 선택이 명시되지 않았다면 기본적으로 `코드로 보여주기`를 우선 제안한다.
|
||||||
|
- 이 규칙은 이후 작업에서도 반복 확인 대상이며, 에이전트는 이를 임의로 생략하지 않는다.
|
||||||
|
|
||||||
|
## 현재 코드베이스에서 특히 주의할 점
|
||||||
|
- `README.md`는 아직 템플릿 상태이므로 실제 동작 방식은 코드와 설정 파일을 기준으로 판단한다.
|
||||||
|
- 예시 스케줄러 코드에는 깨진 문자열과 과도한 반복 로그가 보일 수 있으니 관련 수정 시 인코딩과 로그 정책을 함께 점검한다.
|
||||||
|
- 테스트 코드가 충분하지 않을 수 있으므로 기능 변경 시 필요한 테스트를 보강한다.
|
||||||
|
- 일부 Java 소스와 주석, Swagger 설명에 인코딩이 깨진 문자열이 있으므로 표시 문자열 수정은 영향 범위를 보고 묶어서 처리한다.
|
||||||
|
- `application-local.yaml` 은 로컬 실행값이 직접 들어가 있으므로 공유하거나 커밋할 때 민감정보 노출 여부를 한 번 더 확인한다.
|
||||||
|
|
||||||
|
## 프로파일
|
||||||
|
| 프로파일 | 설명 |
|
||||||
|
|---------|------|
|
||||||
|
| `local` | 로컬 개발 환경 |
|
||||||
|
| `pjt` | 프로젝트(개발) 환경 |
|
||||||
|
|
||||||
|
## 보안 구조
|
||||||
|
- Swagger: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory)
|
||||||
|
- API: JWT Bearer 토큰 인증 (Stateless)
|
||||||
|
- 세션/쿠키: Redis Session 저장소 사용, 쿠키 속성은 프로파일별 `cookie.*` 설정으로 제어
|
||||||
|
- 공개 경로: `/`, `/actuator/health`, `/sso/**`, `/auth/**`, `/user/signup`, `/files/tusHook`
|
||||||
|
- Swagger 인증과 API 인증은 `SecurityFilterChain` 을 분리해서 관리한다.
|
||||||
|
|
||||||
|
## 응답 코드 규칙
|
||||||
|
|
||||||
|
`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` → "회원 정보 조회에 성공하였습니다.")
|
||||||
|
|
||||||
|
## 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/, 필요 시 form/ 구조로 생성
|
||||||
|
- Mapper XML은 `resources/mapper/{moduleName}/` 에 위치
|
||||||
|
- Form 에서 DTO 로 변환할 때는 검증과 trim, 기본값 치환까지 같이 처리하는 현재 패턴을 우선 따른다.
|
||||||
|
- 파일/인증처럼 상태값을 내려주는 DTO 는 `resultCode` 필드를 활용하는 기존 흐름을 해치지 않도록 한다.
|
||||||
|
|
||||||
|
## 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: 컨트롤러 입력 검증용
|
||||||
|
- `useGeneratedKeys`, `keyProperty` 를 사용하는 insert 가 있으므로 신규 PK 생성 테이블은 현재 패턴을 먼저 확인한다.
|
||||||
|
- 상태 집계나 이력성 데이터는 단건 update 외에 이벤트 로그 insert 가 같이 필요한지 확인한다.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM amazoncorretto:21
|
||||||
|
|
||||||
|
# healthcheck용 curl 설치
|
||||||
|
RUN yum -y install curl && yum clean all
|
||||||
|
|
||||||
|
WORKDIR /api
|
||||||
|
|
||||||
|
# build/libs 아래 산출물 이름이 바뀌어도 대응
|
||||||
|
ARG JAR_FILE=build/libs/*.jar
|
||||||
|
COPY ${JAR_FILE} api.jar
|
||||||
|
|
||||||
|
ENV JAVA_OPTS=""
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar /api/api.jar"]
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
// ===== Registry =====
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('2) Build (Gradle)') {
|
||||||
|
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 --no-cache -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
|
||||||
|
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,93 +1,399 @@
|
|||||||
# api
|
# alist API
|
||||||
|
|
||||||
|
Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
## Getting started
|
- 그룹: `com.alist`
|
||||||
|
- Java: `21`
|
||||||
|
- Spring Boot: `3.5.10`
|
||||||
|
- 포트: `8106`
|
||||||
|
- 빌드 결과물: `build/libs/api.jar`
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
## 기술 스택
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
- Spring Web
|
||||||
|
- Spring Security
|
||||||
|
- JWT (`jjwt 0.11.5`)
|
||||||
|
- Spring Session + Redis
|
||||||
|
- MyBatis (`mapper/**/*.xml`)
|
||||||
|
- MariaDB
|
||||||
|
- Swagger / OpenAPI (`springdoc-openapi 2.8.0`)
|
||||||
|
- Validation
|
||||||
|
- Actuator
|
||||||
|
- Lombok
|
||||||
|
- log4jdbc
|
||||||
|
|
||||||
## Add your files
|
## 주요 기능
|
||||||
|
|
||||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
- JWT 발급 및 검증
|
||||||
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
- Redis 기반 세션 관리와 쿠키 설정
|
||||||
|
- Swagger UI Basic 인증 보호
|
||||||
|
- DB 기반 허용 Origin 캐시를 사용하는 동적 CORS
|
||||||
|
- TUS 업로드 초기화, 권한 검증, 상태 조회, 완료 처리, 취소 처리
|
||||||
|
- 파일 조회/다운로드 API
|
||||||
|
- 공통 응답 래퍼 `ApiResponse<T>` 및 `ApiResponseCode` 사용
|
||||||
|
|
||||||
```
|
## 디렉터리 구조
|
||||||
cd existing_repo
|
|
||||||
git remote add origin https://gitlab.pjt.kr/alist/api.git
|
```text
|
||||||
git branch -M main
|
src/main/java/com/alist/api
|
||||||
git push -uf origin main
|
├── common
|
||||||
|
│ ├── modules/file # 공통 파일 업로드/다운로드 모듈
|
||||||
|
│ ├── response # ApiResponse, ApiResponseCode
|
||||||
|
│ └── utils # 공통 유틸리티
|
||||||
|
├── config
|
||||||
|
│ ├── cache # CORS 허용 Origin 캐시
|
||||||
|
│ ├── exception # 전역 예외 처리
|
||||||
|
│ ├── filter # DynamicCorsFilter
|
||||||
|
│ ├── jwt # JWT 인증 관련 구성
|
||||||
|
│ └── properties # 설정 프로퍼티
|
||||||
|
└── modules
|
||||||
|
├── auth # 인증/세션 관련 API
|
||||||
|
├── main # 루트 리다이렉트
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integrate with your tools
|
리소스 파일은 아래 위치를 사용합니다.
|
||||||
|
|
||||||
- [ ] [Set up project integrations](https://gitlab.pjt.kr/alist/api/-/settings/integrations)
|
- 설정: `src/main/resources/application*.yaml`
|
||||||
|
- Mapper XML: `src/main/resources/mapper/**/*.xml`
|
||||||
|
- 로그 설정: `src/main/resources/logback-*.xml`
|
||||||
|
|
||||||
## Collaborate with your team
|
## 실행 방법
|
||||||
|
|
||||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
### 빌드
|
||||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
|
||||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
|
||||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
|
||||||
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
|
||||||
|
|
||||||
## Test and Deploy
|
```bash
|
||||||
|
./gradlew bootJar
|
||||||
|
```
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
### 로컬 실행
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
```bash
|
||||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
./gradlew bootRun --args='--spring.profiles.active=local'
|
||||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
```
|
||||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
|
||||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
|
||||||
|
|
||||||
***
|
### JAR 실행
|
||||||
|
|
||||||
# Editing this README
|
```bash
|
||||||
|
java -jar build/libs/api.jar --spring.profiles.active=local
|
||||||
|
```
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
## 프로파일
|
||||||
|
|
||||||
## Suggestions for a good README
|
| 프로파일 | 설명 | 설정 파일 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `local` | 로컬 개발 환경 | `src/main/resources/application-local.yaml` |
|
||||||
|
| `pjt` | 프로젝트 개발 서버 환경 | `src/main/resources/application-pjt.yaml` |
|
||||||
|
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
공통 설정은 `src/main/resources/application.yaml`에 있습니다.
|
||||||
|
|
||||||
## Name
|
## 필수 설정 항목
|
||||||
Choose a self-explaining name for your project.
|
|
||||||
|
|
||||||
## Description
|
실행 전 아래 설정들이 환경에 맞게 준비되어 있어야 합니다.
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
### 데이터 저장소
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
|
||||||
|
|
||||||
## Visuals
|
- `spring.datasource.*`
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
- `spring.data.redis.*`
|
||||||
|
- `spring.session.*`
|
||||||
|
|
||||||
## Installation
|
### 인증/보안
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
|
||||||
|
|
||||||
## Usage
|
- `jwt.secret`
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
- `jwt.access-token-validity-seconds`
|
||||||
|
- `jwt.refresh-token-validity-seconds`
|
||||||
|
- `cookie.secure`
|
||||||
|
- `cookie.domain`
|
||||||
|
- `cookie.name`
|
||||||
|
- `cookie.same-site`
|
||||||
|
- `swagger.login.id`
|
||||||
|
- `swagger.login.password`
|
||||||
|
|
||||||
## Support
|
### 파일 업로드
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
|
||||||
|
|
||||||
## Roadmap
|
- `file.upload.tus-endpoint`
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
- `file.upload.public-base-url`
|
||||||
|
- `file.upload.tmp-root`
|
||||||
|
- `file.upload.final-root`
|
||||||
|
- `file.upload.interrupt-seconds`
|
||||||
|
- `file.upload.auth-cache.ttl-seconds`
|
||||||
|
|
||||||
## Contributing
|
`pjt` 프로파일은 DB/Redis/JWT/Swagger 값을 환경변수로 주입받도록 작성되어 있습니다.
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
## 보안 구조
|
||||||
|
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
### Swagger
|
||||||
|
|
||||||
## Authors and acknowledgment
|
- 보호 경로: `/v3/api-docs/**`, `/swagger-ui/**`, `/swagger-ui.html`
|
||||||
Show your appreciation to those who have contributed to the project.
|
- 인증 방식: HTTP Basic
|
||||||
|
- 계정 정보: `swagger.login.id`, `swagger.login.password`
|
||||||
|
|
||||||
## License
|
### API
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
- 기본 인증 방식: JWT Bearer
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
- 세션 저장소: Redis
|
||||||
|
- 세션 쿠키: `RedisSessionConfig`에서 도메인, Secure, SameSite 제어
|
||||||
|
|
||||||
|
### 공개 경로
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `/actuator/health`
|
||||||
|
- `/auth/**`
|
||||||
|
- `/files/tusHook`
|
||||||
|
|
||||||
|
루트 `/` 요청은 `/swagger-ui/index.html`로 리다이렉트됩니다.
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
|
||||||
|
`DynamicCorsFilter`가 최우선 필터로 동작하며, 허용 Origin 목록은 `CorsAllowedOriginsCache`에서 조회합니다.
|
||||||
|
|
||||||
|
- 허용된 Origin에만 `Access-Control-Allow-Origin` 설정
|
||||||
|
- Credential 허용
|
||||||
|
- `OPTIONS` preflight 요청은 `200 OK`로 즉시 응답
|
||||||
|
- DB를 직접 매 요청마다 조회하지 않고 캐시된 목록을 사용
|
||||||
|
|
||||||
|
## 파일 업로드/다운로드
|
||||||
|
|
||||||
|
이 프로젝트는 대용량 업로드를 위해 TUS 서버(`tusd`)와 연동합니다. API는 업로드 메타데이터 관리, 업로드 권한 검증, 상태 저장, 완료/취소 처리를 담당합니다.
|
||||||
|
|
||||||
|
### 주요 업로드 엔드포인트
|
||||||
|
|
||||||
|
- `POST /files/uploadInit`
|
||||||
|
- `GET /files/uploadAuth`
|
||||||
|
- `POST /files/uploadStatus`
|
||||||
|
- `POST /files/tusHook`
|
||||||
|
- `POST /files/uploadCancel`
|
||||||
|
|
||||||
|
### 파일 조회 엔드포인트
|
||||||
|
|
||||||
|
- `GET /files/list/{fileMasterIdx}`
|
||||||
|
- `GET /files/view/{fileUuid}`
|
||||||
|
- `GET /files/download/{fileUuid}`
|
||||||
|
|
||||||
|
### 운영 메모
|
||||||
|
|
||||||
|
- TUS 업로드 엔드포인트: `https://file-alist.pjt.kr/tus/files/`
|
||||||
|
- 업로드 임시 경로: `/srv/project/alist/uploads/tmp`
|
||||||
|
- 업로드 최종 경로: `/srv/project/alist/uploads`
|
||||||
|
- 정적 파일 도메인: `https://file-alist.pjt.kr`
|
||||||
|
|
||||||
|
## 주요 인증 엔드포인트
|
||||||
|
|
||||||
|
- `POST /auth/token`
|
||||||
|
- `POST /auth/refresh`
|
||||||
|
- `GET /auth/loginChecked`
|
||||||
|
- `POST /auth/logout`
|
||||||
|
- `POST /user/signup`
|
||||||
|
|
||||||
|
## API 예시 요청/응답
|
||||||
|
|
||||||
|
아래 예시는 실제 컨트롤러의 요청 필드와 `ApiResponse<T>` 응답 구조를 기준으로 정리했습니다.
|
||||||
|
|
||||||
|
### 1. 토큰 발급
|
||||||
|
|
||||||
|
요청:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /auth/token
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": null,
|
||||||
|
"accessToken": "eyJhbGciOiJI..."
|
||||||
|
},
|
||||||
|
"code": "CODE_2001",
|
||||||
|
"message": "임시 토큰 정보 조회에 성공하였습니다."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 테스트 회원가입
|
||||||
|
|
||||||
|
요청:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /test/testSignup
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "testuser01",
|
||||||
|
"password": "pass1234"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"userIdx": 101,
|
||||||
|
"id": "testuser01"
|
||||||
|
},
|
||||||
|
"code": "CODE_2002",
|
||||||
|
"message": "아이디 등록 되었습니다."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
중복일 경우 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"userIdx": null,
|
||||||
|
"id": null
|
||||||
|
},
|
||||||
|
"code": "CODE_2004",
|
||||||
|
"message": "중복된 아이디 정보 입니다."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 파일 업로드 초기화
|
||||||
|
|
||||||
|
요청:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /files/uploadInit
|
||||||
|
Content-Type: application/json
|
||||||
|
Cookie: ALIST_SESSION=...
|
||||||
|
|
||||||
|
{
|
||||||
|
"fileCategory": "notice",
|
||||||
|
"folderPath": "/2026/03",
|
||||||
|
"itemList": [
|
||||||
|
{
|
||||||
|
"originName": "guide.pdf",
|
||||||
|
"sizeBytes": 102400,
|
||||||
|
"contentType": "application/pdf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"fileMasterIdx": 55,
|
||||||
|
"tusEndpoint": "https://file-alist.pjt.kr/tus/files/",
|
||||||
|
"itemList": [
|
||||||
|
{
|
||||||
|
"fileSeq": 1,
|
||||||
|
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111",
|
||||||
|
"uploadToken": "upload-token-sample",
|
||||||
|
"originName": "guide.pdf",
|
||||||
|
"sizeBytes": 102400,
|
||||||
|
"contentType": "application/pdf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"code": "CODE_200",
|
||||||
|
"message": "성공"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 업로드 상태 조회
|
||||||
|
|
||||||
|
요청:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /files/uploadStatus
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111",
|
||||||
|
"status": "UPLOADING",
|
||||||
|
"uploadedBytes": 51200,
|
||||||
|
"totalBytes": 102400,
|
||||||
|
"percent": 50,
|
||||||
|
"updatedAt": "2026-03-11T09:30:00"
|
||||||
|
},
|
||||||
|
"code": "CODE_200",
|
||||||
|
"message": "성공"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
조회 결과가 없을 경우 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"fileUuid": null,
|
||||||
|
"status": null,
|
||||||
|
"uploadedBytes": null,
|
||||||
|
"totalBytes": null,
|
||||||
|
"percent": null,
|
||||||
|
"updatedAt": null
|
||||||
|
},
|
||||||
|
"code": "CODE_2003",
|
||||||
|
"message": "조회된 정보가 없습니다."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Actuator
|
||||||
|
|
||||||
|
외부 노출 대상은 아래와 같습니다.
|
||||||
|
|
||||||
|
- `health`
|
||||||
|
- `info`
|
||||||
|
- `metrics`
|
||||||
|
|
||||||
|
헬스체크 기본 경로:
|
||||||
|
|
||||||
|
- `GET /actuator/health`
|
||||||
|
|
||||||
|
## 응답 규칙
|
||||||
|
|
||||||
|
모든 API 응답은 `ApiResponse<T>` 래퍼를 사용하며, 상태/메시지는 `ApiResponseCode` enum으로 관리합니다.
|
||||||
|
|
||||||
|
자주 사용하는 코드 예시는 아래와 같습니다.
|
||||||
|
|
||||||
|
| 코드 | HTTP Status | 의미 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CODE_200` | `200 OK` | 일반 성공 |
|
||||||
|
| `CODE_204` | `204 No Contnet` | 일반 성공 |
|
||||||
|
| `CODE_2001` | `200 OK` | 단건 조회 성공 |
|
||||||
|
| `CODE_2002` | `201 Created` | 등록 성공 |
|
||||||
|
| `CODE_2003` | `200 OK` | 조회 결과 없음 |
|
||||||
|
| `CODE_2004` | `409 Conflict` | 중복 데이터 |
|
||||||
|
| `CODE_4001` | `400 Bad Request` | 입력값/바인딩 오류 |
|
||||||
|
| `CODE_4003` | `400 Bad Request` | 필수 파라미터 누락 |
|
||||||
|
| `CODE_401` | `401 Unauthorized` | 인증 필요 |
|
||||||
|
| `CODE_403` | `403 Forbidden` | 권한 없음 |
|
||||||
|
| `CODE_500` | `500 Internal Server Error` | 서버 오류 |
|
||||||
|
|
||||||
|
## 배포 관련
|
||||||
|
|
||||||
|
- Dockerfile: `Dockerfile`
|
||||||
|
- Jenkins 파이프라인: `Jenkinsfile.pjt`
|
||||||
|
- 배포 스크립트: `deploy/`
|
||||||
|
- 개발 서버 API 도메인: `https://api-alist.pjt.kr`
|
||||||
|
- 파일 도메인: `https://file-alist.pjt.kr`
|
||||||
|
- Swagger: `https://api-alist.pjt.kr/swagger-ui/index.html`
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- Swagger 로컬 접속: [http://localhost:8106/swagger-ui.html](http://localhost:8106/swagger-ui.html)
|
||||||
|
- 운영 환경에서는 `application-pjt.yaml`의 환경변수 주입 방식 사용을 권장합니다.
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
Proprietary
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id 'org.springframework.boot' version '3.5.10'
|
||||||
|
id 'io.spring.dependency-management' version '1.1.7'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'com.alist'
|
||||||
|
version = '0.0.1-SNAPSHOT'
|
||||||
|
description = 'alist api project'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter'
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
implementation 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
|
||||||
|
// Redis (SSO 세션 공유)
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('test') {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
bootJar {
|
||||||
|
archiveFileName = "api.jar"
|
||||||
|
}
|
||||||
|
jar {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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:8106:8106"
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- /srv/project/alist/env/api/.env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /srv/project/alist/logs/api/app:/logs
|
||||||
|
- /srv/project/alist/uploads:/srv/project/alist/uploads
|
||||||
|
|
||||||
|
environment:
|
||||||
|
JAVA_OPTS: >-
|
||||||
|
-Dspring.profiles.active=${SPRING_PROFILE}
|
||||||
|
-Xms${JVM_XMS}
|
||||||
|
-Xmx${JVM_XMX}
|
||||||
|
-XX:MaxMetaspaceSize=${JVM_META}
|
||||||
|
-Dfile.encoding=UTF-8
|
||||||
|
-Duser.timezone=Asia/Seoul
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
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:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# fix/sso-refresh-session-restore 설명 문서
|
||||||
|
|
||||||
|
## 1. 문서 목적
|
||||||
|
|
||||||
|
이 문서는 backend 프로젝트 `api`의 `fix/sso-refresh-session-restore` 브랜치에서 반영한 **SSO 세션 복원 및 토큰 응답 보강** 변경을 설명합니다.
|
||||||
|
|
||||||
|
핵심 목적은 다음 두 가지입니다.
|
||||||
|
|
||||||
|
- `ALIST_SSO` 쿠키가 없더라도, 유효한 `refreshToken`이 있으면 SSO 세션을 복원할 수 있게 한다.
|
||||||
|
- frontend direct API 흐름에서 필요한 `accessToken` 값을 `/auth/access`, `/auth/refresh` 응답 body로도 내려준다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 배경 문제
|
||||||
|
|
||||||
|
기존 흐름에서는 SSO 관련 엔드포인트가 주로 `ALIST_SSO` 쿠키를 기준으로 로그인 상태를 판단했습니다.
|
||||||
|
|
||||||
|
이 구조에서는 다음 문제가 있었습니다.
|
||||||
|
|
||||||
|
1. 브라우저에 `refreshToken`은 남아 있지만 `ALIST_SSO` 쿠키가 사라진 경우
|
||||||
|
- `/sso/loginChecked`
|
||||||
|
- `/sso/authorize`
|
||||||
|
- `/auth/access`
|
||||||
|
에서 로그인 상태를 복원하지 못했습니다.
|
||||||
|
|
||||||
|
2. frontend가 direct API 흐름으로 변경되면서
|
||||||
|
- `POST /auth/access`
|
||||||
|
- `POST /auth/refresh`
|
||||||
|
응답 body의 `accessToken`을 직접 사용하도록 바뀌었는데,
|
||||||
|
backend 응답은 cookie만 세팅하고 body에는 토큰을 충분히 내려주지 않았습니다.
|
||||||
|
|
||||||
|
즉, **refreshToken 기반 세션 복원**과 **frontend 계약(body accessToken)** 이 동시에 필요했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 변경 대상 파일
|
||||||
|
|
||||||
|
이 브랜치의 핵심 변경 파일은 아래 7개입니다.
|
||||||
|
|
||||||
|
- `src/main/java/com/alist/api/modules/auth/AuthController.java`
|
||||||
|
- `src/main/java/com/alist/api/modules/auth/SsoController.java`
|
||||||
|
- `src/main/java/com/alist/api/modules/auth/dto/SsoAuthorizeDto.java`
|
||||||
|
- `src/main/java/com/alist/api/modules/auth/service/AuthService.java`
|
||||||
|
- `src/main/java/com/alist/api/modules/auth/service/SsoService.java`
|
||||||
|
- `src/main/java/com/alist/api/modules/auth/vo/LoginTokenVo.java`
|
||||||
|
- `src/main/resources/mapper/auth/LoginMapper.xml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 핵심 변경 요약
|
||||||
|
|
||||||
|
### 4-1. `refreshToken` 기반 SSO 세션 복원 추가
|
||||||
|
|
||||||
|
`SsoService`에 아래 역할이 추가되었습니다.
|
||||||
|
|
||||||
|
- `loginChecked(String ssoSessionId, String refreshToken)`
|
||||||
|
- `resolveOrRestoreSsoSessionId(String ssoSessionId, String refreshToken)`
|
||||||
|
- `restoreSsoSessionIfNeeded(String ssoSessionId, String refreshToken)`
|
||||||
|
- `loadSsoSession(String ssoSessionId)`
|
||||||
|
|
||||||
|
동작 방식은 아래와 같습니다.
|
||||||
|
|
||||||
|
1. 먼저 기존 `ALIST_SSO` 쿠키 값(`ssoSessionId`)이 있으면 그대로 사용합니다.
|
||||||
|
2. `ssoSessionId`가 없으면 `refreshToken`으로 사용자 토큰 정보를 조회합니다.
|
||||||
|
3. 유효한 사용자가 확인되면
|
||||||
|
- 기존 최신 SSO 세션이 있으면 TTL을 연장해서 재사용하고
|
||||||
|
- 없으면 Redis에 새로운 SSO 세션을 다시 생성합니다.
|
||||||
|
4. 이후 `loginChecked`, `authorize`, `access`는 복원된 SSO 세션 기준으로 정상 동작합니다.
|
||||||
|
|
||||||
|
즉, **SSO 쿠키가 비어 있어도 refreshToken만 유효하면 공통 로그인 상태를 다시 살릴 수 있게 변경**되었습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-2. `/auth/access` 응답 강화
|
||||||
|
|
||||||
|
`AuthController.access()` 변경 내용:
|
||||||
|
|
||||||
|
- `@CookieValue(name = "refreshToken", required = false)`를 추가하여 refreshToken을 입력으로 받습니다.
|
||||||
|
- `ssoService.loginChecked(ssoSessionId, refreshToken)`을 사용해 SSO 세션을 복원 가능하게 했습니다.
|
||||||
|
- 세션 복원 결과에 `ssoSessionId`가 있으면 `ALIST_SSO` 쿠키를 다시 발급합니다.
|
||||||
|
- 응답 body에 아래 값을 포함합니다.
|
||||||
|
- `isAccessToken`
|
||||||
|
- `userIdx`
|
||||||
|
- `userId`
|
||||||
|
- `userRole`
|
||||||
|
- `accessToken`
|
||||||
|
|
||||||
|
즉, `/auth/access`는 이제 **토큰 쿠키 발급 + frontend용 JSON body accessToken 제공**을 같이 수행합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-3. `/auth/refresh` 응답 강화
|
||||||
|
|
||||||
|
`AuthController.refresh()` 변경 내용:
|
||||||
|
|
||||||
|
- 기존처럼 `accessToken`, `refreshToken` 쿠키는 재발급합니다.
|
||||||
|
- 추가로 응답 body에 아래 값을 내려줍니다.
|
||||||
|
- `refreshed: true`
|
||||||
|
- `accessToken`
|
||||||
|
|
||||||
|
이 변경으로 frontend는 refresh 성공 후 cookie에만 의존하지 않고, **응답 body의 accessToken으로 즉시 service-local 토큰을 다시 기록**할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-4. `/sso/loginChecked` 개선
|
||||||
|
|
||||||
|
`SsoController.loginChecked()` 변경 내용:
|
||||||
|
|
||||||
|
- `refreshToken` cookie를 함께 입력으로 받습니다.
|
||||||
|
- `ssoService.loginChecked(ssoSessionId, refreshToken)`을 사용합니다.
|
||||||
|
- access/refresh token 존재 여부를 기존처럼 함께 반환합니다.
|
||||||
|
- SSO 세션이 복원되면 `ALIST_SSO` 쿠키를 다시 써줍니다.
|
||||||
|
|
||||||
|
즉, `/sso/loginChecked`는 더 이상 단순 조회가 아니라 **필요 시 세션 복원까지 수행하는 확인 엔드포인트**가 되었습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-5. `/sso/authorize` 개선
|
||||||
|
|
||||||
|
`SsoController.authorize()`와 `SsoService.authorize()` 변경 내용:
|
||||||
|
|
||||||
|
- `refreshToken` cookie를 함께 전달받아 세션 복원에 사용합니다.
|
||||||
|
- authorize 성공 시 `SsoAuthorizeDto`에 `ssoSessionId`를 실어주고,
|
||||||
|
- controller에서 해당 값을 기준으로 `ALIST_SSO` 쿠키를 재발급합니다.
|
||||||
|
|
||||||
|
즉, 사용자가 이미 refreshToken을 가지고 있다면 **SSO 쿠키가 비어 있어도 authorize 진입 시 다시 공통 로그인 상태를 회복**할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-6. refreshToken 조회용 Auth 계층 보강
|
||||||
|
|
||||||
|
`AuthService`와 DB 조회 쪽에는 아래 보강이 들어갔습니다.
|
||||||
|
|
||||||
|
- `findUserTokenByRefreshToken(String refreshToken)` 추가
|
||||||
|
- `LoginTokenVo`에 `userIdx`, `userId` 필드 추가
|
||||||
|
- `LoginMapper.xml`의 `selectUserTokenByUserTokenIdx`가 `user_idx`, `user_id`, `user_role`, `refresh_token`을 함께 조회
|
||||||
|
|
||||||
|
이 변경은 SSO 세션 복원 시, 단순 token 유효성 확인을 넘어서 **어떤 사용자의 세션을 복구해야 하는지 식별하기 위해 필요**합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 변경 후 기대 동작
|
||||||
|
|
||||||
|
### 시나리오 A. `ALIST_SSO` 없음 + `refreshToken` 유효
|
||||||
|
|
||||||
|
- `POST /auth/access`
|
||||||
|
- SSO 세션 복원 성공
|
||||||
|
- `ALIST_SSO` 재발급
|
||||||
|
- `accessToken` / `refreshToken` 재발급
|
||||||
|
- body에 `accessToken` 포함
|
||||||
|
|
||||||
|
### 시나리오 B. `GET /sso/loginChecked`
|
||||||
|
|
||||||
|
- 기존에는 로그인 false로 끝날 수 있었던 상황에서
|
||||||
|
- 이제 refreshToken이 유효하면 loggedIn true로 복원 가능
|
||||||
|
|
||||||
|
### 시나리오 C. `GET /sso/authorize`
|
||||||
|
|
||||||
|
- 기존에는 `ALIST_SSO` 없으면 `/login`으로 fallback
|
||||||
|
- 이제 refreshToken으로 세션 복원 가능하면 정상 authorize 진행
|
||||||
|
|
||||||
|
### 시나리오 D. `POST /auth/refresh`
|
||||||
|
|
||||||
|
- cookie 재발급뿐 아니라 body에도 `accessToken` 제공
|
||||||
|
- frontend가 바로 service-local accessToken을 다시 쓸 수 있음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. frontend와의 계약 변화
|
||||||
|
|
||||||
|
이 브랜치는 frontend의 direct API 흐름과 맞물려 있습니다.
|
||||||
|
|
||||||
|
frontend는 현재 아래 계약을 기대합니다.
|
||||||
|
|
||||||
|
- `POST /auth/access` 응답 body에 `accessToken`
|
||||||
|
- `POST /auth/refresh` 응답 body에 `accessToken`
|
||||||
|
|
||||||
|
따라서 이 브랜치 변경은 단순 backend 내부 개선이 아니라,
|
||||||
|
**frontend의 direct login / callback / refresh 흐름을 안정적으로 지원하는 계약 변경**이기도 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 운영상 의미
|
||||||
|
|
||||||
|
이 변경을 적용하면 다음이 좋아집니다.
|
||||||
|
|
||||||
|
- SSO 쿠키 유실 상황에서 세션 복원력이 높아짐
|
||||||
|
- cross-domain authorize 진입 시 로그인 유지가 더 안정적임
|
||||||
|
- frontend가 refresh 후 토큰 재반영을 더 단순하게 처리할 수 있음
|
||||||
|
- `ALIST_SSO`와 `refreshToken`을 각각의 역할에 맞게 유지하면서도 사용자 체감 로그인 끊김을 줄일 수 있음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 검증 포인트
|
||||||
|
|
||||||
|
배포 또는 MR 검토 시 아래를 확인하는 것이 좋습니다.
|
||||||
|
|
||||||
|
1. `POST /auth/access`
|
||||||
|
- `ALIST_SSO` 없이 `refreshToken`만 있을 때 200 또는 정상 복원되는지
|
||||||
|
- response body에 `accessToken`이 포함되는지
|
||||||
|
|
||||||
|
2. `POST /auth/refresh`
|
||||||
|
- response body에 `accessToken`이 포함되는지
|
||||||
|
|
||||||
|
3. `GET /sso/loginChecked`
|
||||||
|
- `refreshToken`만으로 `loggedIn=true`가 가능한지
|
||||||
|
|
||||||
|
4. `GET /sso/authorize`
|
||||||
|
- `ALIST_SSO`가 없더라도 refreshToken 기반으로 redirect가 이어지는지
|
||||||
|
|
||||||
|
5. Redis 상태
|
||||||
|
- `alist:sso:userIdx:*`
|
||||||
|
- `alist:sso:session:*`
|
||||||
|
키가 복원/갱신되는지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 한 줄 요약
|
||||||
|
|
||||||
|
`fix/sso-refresh-session-restore` 브랜치는 **refreshToken만 남아 있는 상황에서도 SSO 세션을 복원하고, `/auth/access` 및 `/auth/refresh`가 frontend가 바로 사용할 수 있는 `accessToken`을 응답 body로 반환하도록 보강한 변경**입니다.
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
Vendored
+94
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'api'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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) {
|
||||||
|
SpringApplication.run(ApiApplication.class, 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,55 @@
|
|||||||
|
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_204 ("204", "컨텐츠 없음", HttpStatus.NO_CONTENT),
|
||||||
|
|
||||||
|
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),
|
||||||
|
CODE_2005("2005", "{0} 요청이 처리되었습니다.", HttpStatus.OK),
|
||||||
|
|
||||||
|
// ===== 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,26 @@
|
|||||||
|
package com.alist.api.common.utils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
public class HashUtils {
|
||||||
|
public static String md5(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
|
||||||
|
byte[] digest = messageDigest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
for (byte b : digest) {
|
||||||
|
builder.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("MD5 algorithm not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.alist.api.common.utils;
|
||||||
|
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
public class SecurityUtil {
|
||||||
|
public static Integer getLoginUserTokenIdx() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication == null || authentication.getPrincipal() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(authentication.getPrincipal().toString());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.alist.api.common.utils;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
|
|
||||||
|
public class SessionUtil {
|
||||||
|
// 쿠키 삭제
|
||||||
|
public static void expireCookie(HttpServletResponse response, String name, String cookieDomain, boolean cookieSecure, String cookieSameSite) {
|
||||||
|
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, "")
|
||||||
|
.path("/")
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(cookieSecure)
|
||||||
|
.sameSite(cookieSameSite)
|
||||||
|
.maxAge(0);
|
||||||
|
|
||||||
|
if (cookieDomain != null && !cookieDomain.isBlank()) {
|
||||||
|
builder.domain(cookieDomain.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
response.addHeader("Set-Cookie", builder.build().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿠키 입력
|
||||||
|
public static void addTokenCookie(HttpServletResponse response, String name, String value, String cookieDomain, boolean cookieSecure, String cookieSameSite, long maxAgeSeconds) {
|
||||||
|
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value)
|
||||||
|
.path("/")
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(cookieSecure)
|
||||||
|
.sameSite(cookieSameSite)
|
||||||
|
.maxAge(maxAgeSeconds);
|
||||||
|
|
||||||
|
if (cookieDomain != null && !cookieDomain.isBlank()) {
|
||||||
|
builder.domain(cookieDomain.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
response.addHeader("Set-Cookie", builder.build().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿠키에서 특정 이름 값 찾기
|
||||||
|
public static String resolveSsoCookieValue(HttpServletRequest request, String cookieName) {
|
||||||
|
if (request.getCookies() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Cookie cookie : request.getCookies()) {
|
||||||
|
if (cookieName.equals(cookie.getName())) {
|
||||||
|
return cookie.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.alist.api.config;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.logging.slf4j.Slf4jImpl;
|
||||||
|
import org.apache.ibatis.session.SqlSessionFactory;
|
||||||
|
import org.mybatis.spring.SqlSessionFactoryBean;
|
||||||
|
import org.mybatis.spring.SqlSessionTemplate;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
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 org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan(
|
||||||
|
basePackages = "com.alist.api.modules",
|
||||||
|
annotationClass = Mapper.class,
|
||||||
|
sqlSessionFactoryRef = "mainSqlSessionFactory"
|
||||||
|
)
|
||||||
|
public class MainDataSourceConfig {
|
||||||
|
|
||||||
|
@Bean(name = "mainDataSourceProperties")
|
||||||
|
@Primary
|
||||||
|
@ConfigurationProperties("spring.datasource")
|
||||||
|
public DataSourceProperties mainDataSourceProperties() {
|
||||||
|
return new DataSourceProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "mainDataSource")
|
||||||
|
@Primary
|
||||||
|
public DataSource mainDataSource(
|
||||||
|
@Qualifier("mainDataSourceProperties") DataSourceProperties mainDataSourceProperties
|
||||||
|
) {
|
||||||
|
return mainDataSourceProperties
|
||||||
|
.initializeDataSourceBuilder()
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "mainSqlSessionFactory")
|
||||||
|
@Primary
|
||||||
|
public SqlSessionFactory mainSqlSessionFactory(
|
||||||
|
@Qualifier("mainDataSource") DataSource mainDataSource
|
||||||
|
) throws Exception {
|
||||||
|
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
|
||||||
|
factoryBean.setDataSource(mainDataSource);
|
||||||
|
factoryBean.setTypeAliasesPackage("com.alist.api");
|
||||||
|
factoryBean.setConfiguration(mybatisConfiguration());
|
||||||
|
factoryBean.setMapperLocations(
|
||||||
|
new PathMatchingResourcePatternResolver()
|
||||||
|
.getResources("classpath:mapper/*/*.xml")
|
||||||
|
);
|
||||||
|
return factoryBean.getObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "mainSqlSessionTemplate")
|
||||||
|
@Primary
|
||||||
|
public SqlSessionTemplate mainSqlSessionTemplate(
|
||||||
|
@Qualifier("mainSqlSessionFactory") SqlSessionFactory mainSqlSessionFactory
|
||||||
|
) {
|
||||||
|
return new SqlSessionTemplate(mainSqlSessionFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "mainTransactionManager")
|
||||||
|
@Primary
|
||||||
|
public DataSourceTransactionManager mainTransactionManager(
|
||||||
|
@Qualifier("mainDataSource") DataSource mainDataSource
|
||||||
|
) {
|
||||||
|
return new DataSourceTransactionManager(mainDataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private org.apache.ibatis.session.Configuration mybatisConfiguration() {
|
||||||
|
org.apache.ibatis.session.Configuration configuration =
|
||||||
|
new org.apache.ibatis.session.Configuration();
|
||||||
|
configuration.setMapUnderscoreToCamelCase(true);
|
||||||
|
configuration.setLogImpl(Slf4jImpl.class);
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,85 @@
|
|||||||
|
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.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
|
||||||
|
.cors(Customizer.withDefaults())
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.exceptionHandling(ex -> ex
|
||||||
|
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
|
||||||
|
.accessDeniedHandler(new JwtAccessDeniedHandler())
|
||||||
|
)
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/files/tusHook").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,73 @@
|
|||||||
|
package com.alist.api.config.cache;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.vo.CorsOriginVo;
|
||||||
|
import com.alist.api.modules.auth.mapper.CorsMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CorsAllowedOriginsCache {
|
||||||
|
|
||||||
|
private final CorsMapper corsMapper;
|
||||||
|
|
||||||
|
// Thread-safe List (volatile로 가시성 보장)
|
||||||
|
private volatile List<String> allowedOrigins = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 시작 시 DB에서 전체 CORS 목록 로딩
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void loadFromDatabase() {
|
||||||
|
refresh();
|
||||||
|
log.info("CORS allowed origins loaded from database");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에서 전체 CORS 목록 다시 로딩
|
||||||
|
*/
|
||||||
|
public synchronized void refresh() {
|
||||||
|
try {
|
||||||
|
List<String> origins = corsMapper.selectCorsAllowedList()
|
||||||
|
.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(CorsOriginVo::getAllowedOrigin)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(origin -> !origin.isEmpty())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
this.allowedOrigins = origins;
|
||||||
|
|
||||||
|
log.info("CORS cache refreshed. Total origins: {}", origins.size());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to refresh CORS cache", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 허용 도메인 목록 조회
|
||||||
|
* @return 허용 도메인 리스트
|
||||||
|
*/
|
||||||
|
public List<String> getAllowedOrigins() {
|
||||||
|
return new ArrayList<>(allowedOrigins);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 캐시 상태 조회
|
||||||
|
* @return 캐시 상태 정보
|
||||||
|
*/
|
||||||
|
public List<String> getCacheStatus() {
|
||||||
|
return new ArrayList<>(allowedOrigins);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(NoResourceFoundException e) {
|
||||||
|
String resourcePath = e.getResourcePath();
|
||||||
|
|
||||||
|
if ("favicon.ico".equals(resourcePath) || "/favicon.ico".equals(resourcePath)) {
|
||||||
|
return ApiResponse.entity(ApiResponseCode.CODE_204);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("Unhandled exception occurred", e);
|
||||||
|
|
||||||
|
return ApiResponse.entity(ApiResponseCode.CODE_404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.alist.api.config.filter;
|
||||||
|
|
||||||
|
import com.alist.api.config.cache.CorsAllowedOriginsCache;
|
||||||
|
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.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DynamicCorsFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final CorsAllowedOriginsCache corsCache;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
String origin = request.getHeader("Origin");
|
||||||
|
|
||||||
|
// 메모리에서 허용 도메인 조회 (DB 조회 없음!)
|
||||||
|
List<String> allowedOrigins = corsCache.getAllowedOrigins();
|
||||||
|
|
||||||
|
// Origin 허용 여부 체크
|
||||||
|
boolean isAllowed = false;
|
||||||
|
if (origin != null && (allowedOrigins.contains("*") || allowedOrigins.contains(origin))) {
|
||||||
|
isAllowed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS 체크 결과 로깅
|
||||||
|
if (origin != null) {
|
||||||
|
if (isAllowed && log.isDebugEnabled()) {
|
||||||
|
log.debug("CORS allowed - Origin: {}", origin);
|
||||||
|
} else if (!isAllowed) {
|
||||||
|
log.warn("CORS blocked - Origin: {}, AllowedOrigins: {}", origin, allowedOrigins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS 헤더 설정
|
||||||
|
if (isAllowed) {
|
||||||
|
response.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
|
response.setHeader("Access-Control-Allow-Credentials", "true");
|
||||||
|
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
||||||
|
response.setHeader("Access-Control-Allow-Headers",
|
||||||
|
"Content-Type, Authorization, X-Requested-With, Accept, Origin, Referer, Content-Disposition, Content-Length");
|
||||||
|
response.setHeader("Access-Control-Expose-Headers", "Authorization, Set-Cookie");
|
||||||
|
response.setHeader("Access-Control-Max-Age", "3600");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight 요청(OPTIONS) 처리
|
||||||
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,71 @@
|
|||||||
|
package com.alist.api.config.jwt;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
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.getUserTokenIdx(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) {
|
||||||
|
// 1) Authorization: Bearer xxx 우선
|
||||||
|
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||||
|
if (bearer != null && bearer.startsWith("Bearer ")) {
|
||||||
|
String token = bearer.substring(7).trim();
|
||||||
|
if (!token.isEmpty()) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 쿠키 fallback (브라우저용)
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies != null) {
|
||||||
|
for (Cookie cookie : cookies) {
|
||||||
|
if ("accessToken".equals(cookie.getName())) {
|
||||||
|
String token = cookie.getValue();
|
||||||
|
if (token != null && !token.isBlank()) {
|
||||||
|
return token.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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(Integer 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(Integer userTokenIdx) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiry = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(String.valueOf(userTokenIdx))
|
||||||
|
.setIssuedAt(Date.from(now))
|
||||||
|
.setExpiration(Date.from(expiry))
|
||||||
|
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 토큰에서 subject 추출 */
|
||||||
|
public String getUserTokenIdx(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 업로드 전용 토큰*/
|
||||||
|
public String createUploadToken(long userTokenIdx) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiry = now.plusSeconds(300); // 5분
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(String.valueOf(userTokenIdx))
|
||||||
|
.claim("scope", "UPLOAD")
|
||||||
|
.setIssuedAt(Date.from(now))
|
||||||
|
.setExpiration(Date.from(expiry))
|
||||||
|
.signWith(secretKey, SignatureAlgorithm.HS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasUploadScope(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = parseClaims(token);
|
||||||
|
return "UPLOAD".equals(claims.get("scope", String.class));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.alist.api.config.migration;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.apache.ibatis.logging.slf4j.Slf4jImpl;
|
||||||
|
import org.apache.ibatis.session.SqlSessionFactory;
|
||||||
|
import org.mybatis.spring.SqlSessionFactoryBean;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan(
|
||||||
|
basePackages = "com.alist.api.modules.migration.alist",
|
||||||
|
sqlSessionFactoryRef = "migrationAlistSqlSessionFactory"
|
||||||
|
)
|
||||||
|
public class AlistDataSourceConfig {
|
||||||
|
|
||||||
|
@Bean(name = "migrationAlistDataSource")
|
||||||
|
@ConfigurationProperties(prefix = "migration.datasource.alist")
|
||||||
|
public HikariDataSource migrationAlistDataSource() {
|
||||||
|
return DataSourceBuilder.create()
|
||||||
|
.type(HikariDataSource.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "migrationAlistSqlSessionFactory")
|
||||||
|
public SqlSessionFactory migrationAlistSqlSessionFactory(
|
||||||
|
@Qualifier("migrationAlistDataSource") DataSource migrationAlistDataSource
|
||||||
|
) throws Exception {
|
||||||
|
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
|
||||||
|
factoryBean.setDataSource(migrationAlistDataSource);
|
||||||
|
factoryBean.setTypeAliasesPackage("com.alist.api.modules.migration.alist");
|
||||||
|
factoryBean.setConfiguration(mybatisConfiguration());
|
||||||
|
factoryBean.setMapperLocations(
|
||||||
|
new PathMatchingResourcePatternResolver()
|
||||||
|
.getResources("classpath:mapper/migration/alist/**/*.xml")
|
||||||
|
);
|
||||||
|
return factoryBean.getObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "migrationAlistTransactionManager")
|
||||||
|
public DataSourceTransactionManager migrationAlistTransactionManager(
|
||||||
|
@Qualifier("migrationAlistDataSource") DataSource migrationAlistDataSource
|
||||||
|
) {
|
||||||
|
return new DataSourceTransactionManager(migrationAlistDataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private org.apache.ibatis.session.Configuration mybatisConfiguration() {
|
||||||
|
org.apache.ibatis.session.Configuration configuration =
|
||||||
|
new org.apache.ibatis.session.Configuration();
|
||||||
|
configuration.setMapUnderscoreToCamelCase(true);
|
||||||
|
configuration.setLogImpl(Slf4jImpl.class);
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.alist.api.config.migration;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.apache.ibatis.logging.slf4j.Slf4jImpl;
|
||||||
|
import org.apache.ibatis.session.SqlSessionFactory;
|
||||||
|
import org.mybatis.spring.SqlSessionFactoryBean;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan(
|
||||||
|
basePackages = "com.alist.api.modules.migration.eltown",
|
||||||
|
sqlSessionFactoryRef = "migrationEltownSqlSessionFactory"
|
||||||
|
)
|
||||||
|
public class EltownDataSourceConfig {
|
||||||
|
|
||||||
|
@Bean(name = "migrationEltownDataSource")
|
||||||
|
@ConfigurationProperties(prefix = "migration.datasource.eltown")
|
||||||
|
public HikariDataSource migrationEltownDataSource() {
|
||||||
|
return DataSourceBuilder.create()
|
||||||
|
.type(HikariDataSource.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "migrationEltownSqlSessionFactory")
|
||||||
|
public SqlSessionFactory migrationEltownSqlSessionFactory(
|
||||||
|
@Qualifier("migrationEltownDataSource") DataSource migrationEltownDataSource
|
||||||
|
) throws Exception {
|
||||||
|
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
|
||||||
|
factoryBean.setDataSource(migrationEltownDataSource);
|
||||||
|
factoryBean.setTypeAliasesPackage("com.alist.api.modules.migration.eltown");
|
||||||
|
factoryBean.setConfiguration(mybatisConfiguration());
|
||||||
|
factoryBean.setMapperLocations(
|
||||||
|
new PathMatchingResourcePatternResolver()
|
||||||
|
.getResources("classpath:mapper/migration/eltown/**/*.xml")
|
||||||
|
);
|
||||||
|
return factoryBean.getObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "migrationEltownTransactionManager")
|
||||||
|
public DataSourceTransactionManager migrationEltownTransactionManager(
|
||||||
|
@Qualifier("migrationEltownDataSource") DataSource migrationEltownDataSource
|
||||||
|
) {
|
||||||
|
return new DataSourceTransactionManager(migrationEltownDataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private org.apache.ibatis.session.Configuration mybatisConfiguration() {
|
||||||
|
org.apache.ibatis.session.Configuration configuration =
|
||||||
|
new org.apache.ibatis.session.Configuration();
|
||||||
|
configuration.setMapUnderscoreToCamelCase(true);
|
||||||
|
configuration.setLogImpl(Slf4jImpl.class);
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,155 @@
|
|||||||
|
package com.alist.api.modules.auth;
|
||||||
|
|
||||||
|
import com.alist.api.common.response.ApiResponse;
|
||||||
|
import com.alist.api.common.response.ApiResponseCode;
|
||||||
|
import com.alist.api.common.utils.SessionUtil;
|
||||||
|
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||||
|
import com.alist.api.modules.auth.dto.SsoExchangeDto;
|
||||||
|
import com.alist.api.modules.auth.dto.LoginDto;
|
||||||
|
import com.alist.api.modules.auth.dto.TokenDto;
|
||||||
|
import com.alist.api.modules.auth.form.ApikeyLoginForm;
|
||||||
|
import com.alist.api.modules.auth.form.TokenForm;
|
||||||
|
import com.alist.api.modules.auth.service.SsoService;
|
||||||
|
import com.alist.api.modules.auth.service.AuthService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag(name = "02. 엑세스 토큰 발급", description = "엑세스 토큰 발급")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/auth")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
@Value("${cookie.secure}")
|
||||||
|
private boolean cookieSecure;
|
||||||
|
|
||||||
|
@Value("${cookie.domain}")
|
||||||
|
private String cookieDomain;
|
||||||
|
|
||||||
|
@Value("${cookie.same-site:Lax}")
|
||||||
|
private String cookieSameSite;
|
||||||
|
|
||||||
|
@Value("${jwt.access-token-validity-seconds}")
|
||||||
|
private long accessTokenValiditySeconds;
|
||||||
|
|
||||||
|
@Value("${jwt.refresh-token-validity-seconds}")
|
||||||
|
private long refreshTokenValiditySeconds;
|
||||||
|
|
||||||
|
@Value("${sso.cookie.name}")
|
||||||
|
private String ssoCookieName;
|
||||||
|
|
||||||
|
private final SsoService ssoService;
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
public AuthController(SsoService ssoService, JwtTokenProvider jwtTokenProvider, AuthService authService) {
|
||||||
|
this.ssoService = ssoService;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "엑세스 토큰 발급 (테스트용 실사용X)",
|
||||||
|
description = "엑세스 토큰이 발급됩니다. (테스트용 실사용X)"
|
||||||
|
)
|
||||||
|
@PostMapping("/token")
|
||||||
|
public ResponseEntity<ApiResponse<TokenDto>> token(@Valid @RequestBody TokenForm tokenForm) {
|
||||||
|
TokenDto tokenDto = new TokenDto();
|
||||||
|
tokenDto.setAccessToken(jwtTokenProvider.createToken(tokenForm.toTokenDto().getId()));
|
||||||
|
|
||||||
|
return ApiResponse.entity(tokenDto, ApiResponseCode.CODE_2001, "엑세스 토큰");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "SSO 기반 토큰 발급",
|
||||||
|
description = "ALIST_SSO 쿠키 기준으로 SSO 로그인 상태를 확인한 뒤 accessToken, refreshToken 쿠키를 발급합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/access")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> access(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
@CookieValue(name = "refreshToken", required = false) String refreshToken
|
||||||
|
) {
|
||||||
|
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
|
||||||
|
SsoExchangeDto ssoSession = ssoService.loginChecked(ssoSessionId, refreshToken);
|
||||||
|
|
||||||
|
if (ssoSession == null) {
|
||||||
|
return ApiResponse.entity(Map.of("isAccessToken", false), ApiResponseCode.CODE_401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ssoSession.getSsoSessionId() != null && !ssoSession.getSsoSessionId().isBlank()) {
|
||||||
|
SessionUtil.addTokenCookie(response, ssoCookieName, ssoSession.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
String accessToken = jwtTokenProvider.createAccessToken(ssoSession.getUserTokenIdx(), ssoSession.getUserRole());
|
||||||
|
String issuedRefreshToken = jwtTokenProvider.createRefreshToken(ssoSession.getUserTokenIdx());
|
||||||
|
|
||||||
|
LoginDto loginDto = new LoginDto();
|
||||||
|
loginDto.setUserTokenIdx(ssoSession.getUserTokenIdx());
|
||||||
|
loginDto.setRefreshToken(issuedRefreshToken);
|
||||||
|
|
||||||
|
authService.updateRefreshToken(loginDto);
|
||||||
|
|
||||||
|
SessionUtil.addTokenCookie(response, "accessToken", accessToken, cookieDomain, cookieSecure, cookieSameSite, accessTokenValiditySeconds);
|
||||||
|
SessionUtil.addTokenCookie(response,"refreshToken", issuedRefreshToken, cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("isAccessToken", true);
|
||||||
|
result.put("userIdx", ssoSession.getUserIdx());
|
||||||
|
result.put("userId", ssoSession.getUserId());
|
||||||
|
result.put("userRole", ssoSession.getUserRole());
|
||||||
|
result.put("accessToken", accessToken);
|
||||||
|
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "토큰");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "리프레시 토큰으로 엑세스 토큰 재발급",
|
||||||
|
description = "리프레시 토큰으로 엑세스 토큰 재발급"
|
||||||
|
)
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> refresh(
|
||||||
|
@CookieValue(name = "refreshToken", required = false) String refreshToken
|
||||||
|
, HttpServletResponse response
|
||||||
|
) {
|
||||||
|
|
||||||
|
LoginDto result = authService.refreshByToken(refreshToken);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
return ApiResponse.entity(Map.of("refreshed", false), ApiResponseCode.CODE_401);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionUtil.addTokenCookie(response, "accessToken", result.getAccessToken(), cookieDomain, cookieSecure, cookieSameSite, accessTokenValiditySeconds);
|
||||||
|
SessionUtil.addTokenCookie(response, "refreshToken", result.getRefreshToken(), cookieDomain, cookieSecure, cookieSameSite, refreshTokenValiditySeconds);
|
||||||
|
|
||||||
|
Map<String, Object> responseBody = new HashMap<>();
|
||||||
|
responseBody.put("refreshed", true);
|
||||||
|
responseBody.put("accessToken", result.getAccessToken());
|
||||||
|
|
||||||
|
return ApiResponse.entity(responseBody, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "API Key 로그인",
|
||||||
|
description = "API Key로 인증하고 JSON으로 토큰을 반환합니다. (OpenAPI/외부 서비스용)"
|
||||||
|
)
|
||||||
|
@PostMapping("/apiKeyLogin")
|
||||||
|
public ResponseEntity<ApiResponse<LoginDto>> apiKeyLogin(@Valid @RequestBody ApikeyLoginForm apiKeyLoginForm) {
|
||||||
|
LoginDto result = authService.selectUserApiKeyLogin(apiKeyLoginForm.toLoginDto());
|
||||||
|
|
||||||
|
if (result.getResultCode() == 2003) {
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2003);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "엑세스 토큰");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.alist.api.modules.auth;
|
||||||
|
|
||||||
|
import com.alist.api.common.response.ApiResponse;
|
||||||
|
import com.alist.api.common.response.ApiResponseCode;
|
||||||
|
import com.alist.api.config.cache.CorsAllowedOriginsCache;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Tag(name = "04. CRS 갱신", description = "테스트 CORS 설정 관리 API")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/cors")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CorsAllowController {
|
||||||
|
|
||||||
|
private final CorsAllowedOriginsCache corsCache;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "CORS 화이트리스트 갱신",
|
||||||
|
description = "DB에서 CORS 허용 도메인 목록을 다시 로딩합니다. DB에 도메인 추가 후 호출하세요."
|
||||||
|
)
|
||||||
|
@PostMapping("/reload")
|
||||||
|
public ResponseEntity<ApiResponse<List<String>>> reloadCors() {
|
||||||
|
corsCache.refresh();
|
||||||
|
List<String> cacheStatus = corsCache.getCacheStatus();
|
||||||
|
return ApiResponse.entity(cacheStatus, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.alist.api.modules.auth;
|
||||||
|
|
||||||
|
import com.alist.api.common.response.ApiResponse;
|
||||||
|
import com.alist.api.common.response.ApiResponseCode;
|
||||||
|
import com.alist.api.common.utils.SessionUtil;
|
||||||
|
import com.alist.api.modules.auth.dto.SsoAuthorizeDto;
|
||||||
|
import com.alist.api.modules.auth.dto.SsoExchangeDto;
|
||||||
|
import com.alist.api.modules.auth.dto.SsoLoginCheckDto;
|
||||||
|
import com.alist.api.modules.auth.dto.SsoLoginDto;
|
||||||
|
import com.alist.api.modules.auth.form.SsoExchangeForm;
|
||||||
|
import com.alist.api.modules.auth.form.SsoLoginForm;
|
||||||
|
import com.alist.api.modules.auth.service.SsoService;
|
||||||
|
import com.alist.api.modules.auth.service.AuthService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag(name = "01. SSO 인증", description = "공통 로그인, 인가 코드 발급, 코드 교환, 로그아웃 관련 API")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/sso")
|
||||||
|
public class SsoController {
|
||||||
|
@Value("${cookie.secure}")
|
||||||
|
private boolean cookieSecure;
|
||||||
|
|
||||||
|
@Value("${cookie.domain:}")
|
||||||
|
private String cookieDomain;
|
||||||
|
|
||||||
|
@Value("${cookie.same-site:Lax}")
|
||||||
|
private String cookieSameSite;
|
||||||
|
|
||||||
|
@Value("${sso.session.ttl-seconds}")
|
||||||
|
private long ssoSessionTtlSeconds;
|
||||||
|
|
||||||
|
@Value("${sso.cookie.name}")
|
||||||
|
private String ssoCookieName;
|
||||||
|
private final SsoService ssoService;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
public SsoController(SsoService ssoService, AuthService authService) {
|
||||||
|
this.ssoService = ssoService;
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "SSO 로그인",
|
||||||
|
description = "아이디/비밀번호로 공통 SSO 로그인 상태를 생성하고 HttpOnly 쿠키를 발급합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<ApiResponse<SsoLoginCheckDto>> login(
|
||||||
|
@Valid @RequestBody SsoLoginForm ssoLoginForm
|
||||||
|
, HttpServletRequest request
|
||||||
|
, HttpServletResponse response
|
||||||
|
) {
|
||||||
|
SsoLoginDto ssoLoginDto = ssoService.login(ssoLoginForm.toSsoLoginDto(), request);
|
||||||
|
|
||||||
|
SsoLoginCheckDto result = new SsoLoginCheckDto();
|
||||||
|
|
||||||
|
if (ssoLoginDto.getResultCode() == 2003) {
|
||||||
|
result.setLoginIn(false);
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2003);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionUtil.addTokenCookie(response, ssoCookieName, ssoLoginDto.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite, ssoSessionTtlSeconds);
|
||||||
|
|
||||||
|
result.setLoginIn(true);
|
||||||
|
result.setId(ssoLoginDto.getUserId());
|
||||||
|
result.setUserIdx(ssoLoginDto.getUserIdx());
|
||||||
|
result.setRole(ssoLoginDto.getUserRole());
|
||||||
|
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "SSO 로그인");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "SSO 로그인 상태 확인",
|
||||||
|
description = "SSO 쿠키 기준으로 현재 공통 로그인 상태가 유효한지 확인하고, access/refresh 토큰 존재 여부도 함께 반환합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/loginChecked")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> loginChecked(
|
||||||
|
HttpServletRequest request
|
||||||
|
, HttpServletResponse response
|
||||||
|
, @CookieValue(name = "refreshToken", required = false) String refreshToken
|
||||||
|
) {
|
||||||
|
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
SsoExchangeDto ssoSession = ssoService.loginChecked(ssoSessionId, refreshToken);
|
||||||
|
|
||||||
|
String accessToken = SessionUtil.resolveSsoCookieValue(request, "accessToken");
|
||||||
|
String refreshTokenValue = SessionUtil.resolveSsoCookieValue(request, "refreshToken");
|
||||||
|
|
||||||
|
result.put("isAccessToken", accessToken != null && !accessToken.trim().isEmpty());
|
||||||
|
result.put("isRefreshToken", refreshTokenValue != null && !refreshTokenValue.trim().isEmpty());
|
||||||
|
|
||||||
|
if (ssoSession == null) {
|
||||||
|
result.put("loggedIn", false);
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionUtil.addTokenCookie(response, ssoCookieName, ssoSession.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite,ssoSessionTtlSeconds);
|
||||||
|
|
||||||
|
result.put("loggedIn", true);
|
||||||
|
result.put("ssoSessionId", ssoSession.getSsoSessionId());
|
||||||
|
result.put("userId", ssoSession.getUserId());
|
||||||
|
result.put("userIdx", ssoSession.getUserIdx());
|
||||||
|
result.put("userTokenIdx", ssoSession.getUserTokenIdx());
|
||||||
|
result.put("userRole", ssoSession.getUserRole());
|
||||||
|
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "SSO 인가 코드 발급",
|
||||||
|
description = "공통 로그인 상태를 확인한 뒤 대상 서비스로 이동할 1회용 인가 코드를 발급하고 redirectUri 로 리다이렉트합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/authorize")
|
||||||
|
public ResponseEntity<Void> authorize(
|
||||||
|
@RequestParam("clientId") String clientId
|
||||||
|
, @RequestParam("redirectUri") String redirectUri
|
||||||
|
, @RequestParam(value = "state", required = false) String state
|
||||||
|
, HttpServletRequest request
|
||||||
|
, HttpServletResponse response
|
||||||
|
, @CookieValue(name = "refreshToken", required = false) String refreshToken
|
||||||
|
) {
|
||||||
|
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
|
||||||
|
SsoAuthorizeDto authorizeResult = ssoService.authorize(clientId, redirectUri, state, ssoSessionId, refreshToken);
|
||||||
|
|
||||||
|
if (authorizeResult.getSsoSessionId() != null && !authorizeResult.getSsoSessionId().isBlank()) {
|
||||||
|
SessionUtil.addTokenCookie(response, ssoCookieName, authorizeResult.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite, ssoSessionTtlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.status(302).location(URI.create(authorizeResult.getRedirectUrl())).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "SSO 인가 코드 교환",
|
||||||
|
description = "서비스가 전달받은 1회용 인가 코드를 검증하고, 자체 세션 생성에 필요한 사용자 정보를 반환합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/exchange")
|
||||||
|
public ResponseEntity<ApiResponse<SsoExchangeDto>> exchange(
|
||||||
|
@Valid @RequestBody SsoExchangeForm ssoExchangeForm
|
||||||
|
, HttpServletResponse response
|
||||||
|
) {
|
||||||
|
SsoExchangeDto result = ssoService.exchange(ssoExchangeForm.toSsoExchangeDto());
|
||||||
|
|
||||||
|
SessionUtil.addTokenCookie(response, ssoCookieName, result.getSsoSessionId(), cookieDomain, cookieSecure, cookieSameSite, ssoSessionTtlSeconds);
|
||||||
|
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "SSO code 교환");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "SSO 로그아웃",
|
||||||
|
description = "공통 로그인 세션을 만료시켜 이후 authorize 및 서비스 접근 시 재로그인이 필요하도록 처리합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> logout(
|
||||||
|
HttpServletRequest request
|
||||||
|
, HttpServletResponse response
|
||||||
|
, @CookieValue(name = "refreshToken", required = false) String refreshToken
|
||||||
|
) {
|
||||||
|
String ssoSessionId = SessionUtil.resolveSsoCookieValue(request, ssoCookieName);
|
||||||
|
|
||||||
|
ssoService.logout(ssoSessionId);
|
||||||
|
authService.clearRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
SessionUtil.expireCookie(response, ssoCookieName, cookieDomain, cookieSecure, cookieSameSite);
|
||||||
|
SessionUtil.expireCookie(response, "accessToken", cookieDomain, cookieSecure, cookieSameSite);
|
||||||
|
SessionUtil.expireCookie(response, "refreshToken", cookieDomain, cookieSecure, cookieSameSite);
|
||||||
|
|
||||||
|
return ApiResponse.entity(Map.of("logout", true), ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.alist.api.modules.auth.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class LoginDto {
|
||||||
|
@Schema(description = "아이디")
|
||||||
|
private String id;
|
||||||
|
@Schema(description = "엑세스 토큰")
|
||||||
|
private String accessToken;
|
||||||
|
@Schema(description = "리플레시 토큰")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private String password;
|
||||||
|
@JsonIgnore
|
||||||
|
private String userApiKey;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
@JsonIgnore
|
||||||
|
private Instant expiresAt;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer userIdx;
|
||||||
|
@JsonIgnore
|
||||||
|
private int resultCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.alist.api.modules.auth.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class SsoAuthorizeDto {
|
||||||
|
private String redirectUrl;
|
||||||
|
private String ssoSessionId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.alist.api.modules.auth.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class SsoExchangeDto {
|
||||||
|
|
||||||
|
private String code;
|
||||||
|
private String clientId;
|
||||||
|
|
||||||
|
private String ssoSessionId;
|
||||||
|
private Integer userIdx;
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private String userId;
|
||||||
|
private String userRole;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.alist.api.modules.auth.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class SsoLoginCheckDto {
|
||||||
|
private boolean loginIn;
|
||||||
|
private String id;
|
||||||
|
private Integer userIdx;
|
||||||
|
private String role;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.alist.api.modules.auth.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class SsoLoginDto {
|
||||||
|
private String id;
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
private int resultCode;
|
||||||
|
private String ssoSessionId;
|
||||||
|
private Integer userIdx;
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private String userId;
|
||||||
|
private String userRole;
|
||||||
|
|
||||||
|
public LoginDto toLoginDto() {
|
||||||
|
LoginDto loginDto = new LoginDto();
|
||||||
|
loginDto.setId(this.id);
|
||||||
|
loginDto.setPassword(this.password);
|
||||||
|
return loginDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.alist.api.modules.auth.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class TokenDto {
|
||||||
|
@Schema(description = "아이디")
|
||||||
|
private String id;
|
||||||
|
@Schema(description = "엑세스 토큰")
|
||||||
|
private String accessToken;
|
||||||
|
@Schema(description = "리플레시 토큰")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private int resultCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.alist.api.modules.auth.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.dto.LoginDto;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class ApikeyLoginForm {
|
||||||
|
@Schema(
|
||||||
|
description = "api key",
|
||||||
|
example = "aaaa111dsddd"
|
||||||
|
)
|
||||||
|
@NotBlank(message = "apiKey를 입력해주세요.")
|
||||||
|
private String userApiKey;
|
||||||
|
|
||||||
|
public LoginDto toLoginDto() {
|
||||||
|
LoginDto loginDto = new LoginDto();
|
||||||
|
loginDto.setUserApiKey(this.userApiKey.trim());
|
||||||
|
|
||||||
|
return loginDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.alist.api.modules.auth.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.dto.SsoExchangeDto;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class SsoExchangeForm {
|
||||||
|
@NotBlank
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String clientId;
|
||||||
|
|
||||||
|
public SsoExchangeDto toSsoExchangeDto() {
|
||||||
|
SsoExchangeDto ssoExchangeDto = new SsoExchangeDto();
|
||||||
|
ssoExchangeDto.setCode(code == null ? null : code.trim());
|
||||||
|
ssoExchangeDto.setClientId(clientId == null ? null : clientId.trim());
|
||||||
|
return ssoExchangeDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.alist.api.modules.auth.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.dto.SsoLoginDto;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class SsoLoginForm {
|
||||||
|
@NotBlank
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
public SsoLoginDto toSsoLoginDto() {
|
||||||
|
SsoLoginDto ssoLoginDto = new SsoLoginDto();
|
||||||
|
ssoLoginDto.setId(id == null ? null : id.trim());
|
||||||
|
ssoLoginDto.setPassword(password == null ? null : password.trim());
|
||||||
|
return ssoLoginDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.alist.api.modules.auth.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.dto.TokenDto;
|
||||||
|
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 = "test")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
public TokenDto toTokenDto() {
|
||||||
|
TokenDto tokenDto = new TokenDto();
|
||||||
|
tokenDto.setId(this.id.trim());
|
||||||
|
|
||||||
|
return tokenDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.alist.api.modules.auth.mapper;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.dto.LoginDto;
|
||||||
|
import com.alist.api.modules.auth.vo.LoginTokenVo;
|
||||||
|
import com.alist.api.modules.auth.vo.LoginVo;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface AuthMapper {
|
||||||
|
LoginVo selectLogin(LoginDto loginDto);
|
||||||
|
|
||||||
|
LoginTokenVo selectUserTokenByUserId(LoginDto loginDto);
|
||||||
|
|
||||||
|
LoginTokenVo selectUserTokenByUserApiKey(LoginDto loginDto);
|
||||||
|
|
||||||
|
void updateRefreshToken(LoginDto loginDto);
|
||||||
|
|
||||||
|
LoginTokenVo selectUserTokenByUserTokenIdx(int userTokenIdx);
|
||||||
|
|
||||||
|
void clearRefreshTokenByUserTokenIdx(int userTokenIdx);
|
||||||
|
|
||||||
|
void updateLoginByLastAt(LoginDto loginDto);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.alist.api.modules.auth.mapper;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.vo.CorsOriginVo;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CorsMapper {
|
||||||
|
/**
|
||||||
|
* 전체 CORS 허용 도메인 목록 조회 (user_token_idx별로)
|
||||||
|
* @return CorsOriginDto 리스트
|
||||||
|
*/
|
||||||
|
List<CorsOriginVo> selectCorsAllowedList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.alist.api.modules.auth.mapper;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.vo.SsoClientVo;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SsoMapper {
|
||||||
|
SsoClientVo selectSsoClient(String clientId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.alist.api.modules.auth.service;
|
||||||
|
|
||||||
|
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||||
|
import com.alist.api.config.properties.JwtProperties;
|
||||||
|
import com.alist.api.modules.auth.dto.LoginDto;
|
||||||
|
import com.alist.api.modules.auth.mapper.AuthMapper;
|
||||||
|
import com.alist.api.modules.auth.vo.LoginTokenVo;
|
||||||
|
import com.alist.api.modules.auth.vo.LoginVo;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class AuthService {
|
||||||
|
private final AuthMapper authMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final JwtProperties jwtProperties;
|
||||||
|
|
||||||
|
public AuthService(AuthMapper authMapper, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, JwtProperties jwtProperties) {
|
||||||
|
this.authMapper = authMapper;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
this.jwtProperties = jwtProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// id login
|
||||||
|
public LoginDto selectLogin(LoginDto loginDto) {
|
||||||
|
LoginVo loginVo = authMapper.selectLogin(loginDto);
|
||||||
|
|
||||||
|
if (loginVo != null) {
|
||||||
|
if (passwordEncoder.matches(loginDto.getPassword(), loginVo.getPassword())) {
|
||||||
|
|
||||||
|
loginDto.setUserIdx(loginVo.getUserIdx());
|
||||||
|
|
||||||
|
LoginTokenVo loginTokenVo = authMapper.selectUserTokenByUserId(loginDto);
|
||||||
|
|
||||||
|
if (loginTokenVo != null) {
|
||||||
|
loginDto.setAccessToken(jwtTokenProvider.createAccessToken(loginTokenVo.getUserTokenIdx(), loginTokenVo.getUserRole()));
|
||||||
|
loginDto.setRefreshToken(jwtTokenProvider.createRefreshToken(loginTokenVo.getUserTokenIdx()));
|
||||||
|
|
||||||
|
loginDto.setUserTokenIdx(loginTokenVo.getUserTokenIdx());
|
||||||
|
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiresAt = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
|
||||||
|
|
||||||
|
loginDto.setExpiresAt(expiresAt);
|
||||||
|
|
||||||
|
authMapper.updateRefreshToken(loginDto);
|
||||||
|
|
||||||
|
// 마지막 로그인 시간 추가
|
||||||
|
authMapper.updateLoginByLastAt(loginDto);
|
||||||
|
|
||||||
|
loginDto.setResultCode(2001);
|
||||||
|
} else {
|
||||||
|
loginDto.setResultCode(2003);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loginDto.setResultCode(2003);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loginDto.setResultCode(2003);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loginDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// api key login
|
||||||
|
public LoginDto selectUserApiKeyLogin(LoginDto loginDto) {
|
||||||
|
if (loginDto.getUserApiKey() != null) {
|
||||||
|
LoginTokenVo loginTokenVo = authMapper.selectUserTokenByUserApiKey(loginDto);
|
||||||
|
|
||||||
|
if (loginTokenVo != null) {
|
||||||
|
loginDto.setAccessToken(jwtTokenProvider.createAccessToken(loginTokenVo.getUserTokenIdx(), loginTokenVo.getUserRole()));
|
||||||
|
loginDto.setRefreshToken(jwtTokenProvider.createRefreshToken(loginTokenVo.getUserTokenIdx()));
|
||||||
|
|
||||||
|
loginDto.setResultCode(2001);
|
||||||
|
} else {
|
||||||
|
loginDto.setResultCode(2003);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loginDto.setResultCode(2003);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loginDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginDto refreshByToken(String refreshToken) {
|
||||||
|
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int userTokenIdx;
|
||||||
|
try {
|
||||||
|
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginTokenVo tokenVo = authMapper.selectUserTokenByUserTokenIdx(userTokenIdx);
|
||||||
|
if (tokenVo == null || tokenVo.getRefreshToken() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refreshToken.equals(tokenVo.getRefreshToken())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginDto result = new LoginDto();
|
||||||
|
result.setUserTokenIdx(userTokenIdx);
|
||||||
|
result.setAccessToken(jwtTokenProvider.createAccessToken(tokenVo.getUserTokenIdx(), tokenVo.getUserRole()));
|
||||||
|
result.setRefreshToken(jwtTokenProvider.createRefreshToken(tokenVo.getUserTokenIdx()));
|
||||||
|
result.setExpiresAt(Instant.now().plusSeconds(jwtProperties.getRefreshTokenValiditySeconds()));
|
||||||
|
|
||||||
|
authMapper.updateRefreshToken(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginTokenVo findUserTokenByRefreshToken(String refreshToken) {
|
||||||
|
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int userTokenIdx;
|
||||||
|
try {
|
||||||
|
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginTokenVo tokenVo = authMapper.selectUserTokenByUserTokenIdx(userTokenIdx);
|
||||||
|
if (tokenVo == null || tokenVo.getRefreshToken() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refreshToken.equals(tokenVo.getRefreshToken())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenVo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearRefreshToken(String refreshToken) {
|
||||||
|
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserTokenIdx(refreshToken));
|
||||||
|
authMapper.clearRefreshTokenByUserTokenIdx(userTokenIdx);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateRefreshToken(LoginDto loginDto) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiresAt = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
|
||||||
|
|
||||||
|
loginDto.setExpiresAt(expiresAt);
|
||||||
|
|
||||||
|
authMapper.updateRefreshToken(loginDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package com.alist.api.modules.auth.service;
|
||||||
|
|
||||||
|
import com.alist.api.modules.auth.dto.SsoAuthorizeDto;
|
||||||
|
import com.alist.api.modules.auth.dto.SsoExchangeDto;
|
||||||
|
import com.alist.api.modules.auth.dto.SsoLoginDto;
|
||||||
|
import com.alist.api.modules.auth.dto.LoginDto;
|
||||||
|
import com.alist.api.modules.auth.vo.LoginTokenVo;
|
||||||
|
import com.alist.api.modules.auth.mapper.SsoMapper;
|
||||||
|
import com.alist.api.modules.auth.vo.SsoClientVo;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SsoService {
|
||||||
|
private final AuthService authService;
|
||||||
|
private final SsoMapper ssoMapper;
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final Duration ssoSessionTtl;
|
||||||
|
private final Duration ssoCodeTtl;
|
||||||
|
|
||||||
|
public SsoService(AuthService authService, SsoMapper ssoMapper, StringRedisTemplate redisTemplate, @Value("${sso.session.ttl-seconds}") long ssoSessionTtlSeconds, @Value("${sso.code.ttl-seconds}") long ssoCodeTtlSeconds) {
|
||||||
|
this.authService = authService;
|
||||||
|
this.ssoMapper = ssoMapper;
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.ssoSessionTtl = Duration.ofSeconds(ssoSessionTtlSeconds);
|
||||||
|
this.ssoCodeTtl = Duration.ofSeconds(ssoCodeTtlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SsoLoginDto login(SsoLoginDto ssoLoginDto, HttpServletRequest request) {
|
||||||
|
LoginDto loginDto = authService.selectLogin(ssoLoginDto.toLoginDto());
|
||||||
|
|
||||||
|
SsoLoginDto result = new SsoLoginDto();
|
||||||
|
result.setResultCode(loginDto.getResultCode());
|
||||||
|
|
||||||
|
if (loginDto.getResultCode() == 2003) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ssoSessionId = "SSO_" + UUID.randomUUID();
|
||||||
|
String latestSessionKey = "alist:sso:userIdx:" + loginDto.getUserIdx();
|
||||||
|
String sessionKey = "alist:sso:session:" + ssoSessionId;
|
||||||
|
|
||||||
|
String oldSsoSessionId = redisTemplate.opsForValue().get(latestSessionKey);
|
||||||
|
if (oldSsoSessionId != null && !oldSsoSessionId.isBlank()) {
|
||||||
|
redisTemplate.delete("alist:sso:session:" + oldSsoSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userIdx", String.valueOf(loginDto.getUserIdx()));
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userTokenIdx", String.valueOf(loginDto.getUserTokenIdx()));
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userId", loginDto.getId());
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userRole", "USER");
|
||||||
|
redisTemplate.expire(sessionKey, ssoSessionTtl);
|
||||||
|
|
||||||
|
redisTemplate.opsForValue().set(latestSessionKey, ssoSessionId, ssoSessionTtl);
|
||||||
|
|
||||||
|
result.setSsoSessionId(ssoSessionId);
|
||||||
|
result.setUserId(loginDto.getId());
|
||||||
|
result.setUserIdx(loginDto.getUserIdx());
|
||||||
|
result.setUserRole("USER");
|
||||||
|
result.setResultCode(200);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SsoExchangeDto loginChecked(String ssoSessionId) {
|
||||||
|
return loginChecked(ssoSessionId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SsoExchangeDto loginChecked(String ssoSessionId, String refreshToken) {
|
||||||
|
String resolvedSsoSessionId = resolveOrRestoreSsoSessionId(ssoSessionId, refreshToken);
|
||||||
|
if (resolvedSsoSessionId == null || resolvedSsoSessionId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadSsoSession(resolvedSsoSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolveOrRestoreSsoSessionId(String ssoSessionId, String refreshToken) {
|
||||||
|
return restoreSsoSessionIfNeeded(ssoSessionId, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SsoExchangeDto loadSsoSession(String ssoSessionId) {
|
||||||
|
if (ssoSessionId == null || ssoSessionId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionKey = "alist:sso:session:" + ssoSessionId;
|
||||||
|
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
|
||||||
|
|
||||||
|
if (userIdx == null || userIdx.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String latestSsoSessionId = redisTemplate.opsForValue().get("alist:sso:userIdx:" + userIdx);
|
||||||
|
if (!ssoSessionId.equals(latestSsoSessionId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSsoSessionTtl(ssoSessionId, userIdx);
|
||||||
|
|
||||||
|
String userId = (String) redisTemplate.opsForHash().get(sessionKey, "userId");
|
||||||
|
String userTokenIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userTokenIdx");
|
||||||
|
String userRole = (String) redisTemplate.opsForHash().get(sessionKey, "userRole");
|
||||||
|
|
||||||
|
SsoExchangeDto result = new SsoExchangeDto();
|
||||||
|
result.setSsoSessionId(ssoSessionId);
|
||||||
|
result.setUserId(userId);
|
||||||
|
result.setUserIdx(userIdx == null ? null : Integer.parseInt(userIdx));
|
||||||
|
result.setUserTokenIdx(userTokenIdx == null ? null : Integer.parseInt(userTokenIdx));
|
||||||
|
result.setUserRole(userRole);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SsoAuthorizeDto authorize(String clientId, String redirectUri, String state, String ssoSessionId) {
|
||||||
|
return authorize(clientId, redirectUri, state, ssoSessionId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SsoAuthorizeDto authorize(String clientId, String redirectUri, String state, String ssoSessionId, String refreshToken) {
|
||||||
|
SsoAuthorizeDto result = new SsoAuthorizeDto();
|
||||||
|
String resolvedSsoSessionId = resolveOrRestoreSsoSessionId(ssoSessionId, refreshToken);
|
||||||
|
|
||||||
|
if (resolvedSsoSessionId == null || resolvedSsoSessionId.isBlank()) {
|
||||||
|
result.setRedirectUrl("/login");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionKey = "alist:sso:session:" + resolvedSsoSessionId;
|
||||||
|
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
|
||||||
|
if (userIdx == null) {
|
||||||
|
result.setRedirectUrl("/login");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
String latestSsoSessionId = redisTemplate.opsForValue().get("alist:sso:userIdx:" + userIdx);
|
||||||
|
if (!resolvedSsoSessionId.equals(latestSsoSessionId)) {
|
||||||
|
result.setRedirectUrl("/login");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
SsoClientVo ssoClientVo = ssoMapper.selectSsoClient(clientId);
|
||||||
|
if (ssoClientVo == null) {
|
||||||
|
throw new IllegalArgumentException("허용되지 않은 clientId 또는 redirectUri 입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"Y".equalsIgnoreCase(ssoClientVo.getUseYn())) {
|
||||||
|
throw new IllegalArgumentException("비활성화된 SSO 클라이언트입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSsoSessionTtl(resolvedSsoSessionId, userIdx);
|
||||||
|
|
||||||
|
String code = "CODE_" + UUID.randomUUID();
|
||||||
|
String codeKey = "alist:sso:code:" + code;
|
||||||
|
|
||||||
|
redisTemplate.opsForHash().put(codeKey, "ssoSessionId", resolvedSsoSessionId);
|
||||||
|
redisTemplate.opsForHash().put(codeKey, "clientId", clientId);
|
||||||
|
redisTemplate.opsForHash().put(codeKey, "redirectUri", redirectUri);
|
||||||
|
redisTemplate.expire(codeKey, ssoCodeTtl);
|
||||||
|
|
||||||
|
String encodedCode = URLEncoder.encode(code, StandardCharsets.UTF_8);
|
||||||
|
String encodedState = state == null ? "" : "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
URI uri = URI.create(redirectUri);
|
||||||
|
String base = uri.getScheme() + "://" + uri.getAuthority() + uri.getPath();
|
||||||
|
String query = uri.getQuery();
|
||||||
|
|
||||||
|
StringBuilder redirect = new StringBuilder(base);
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
redirect.append("?code=").append(encodedCode);
|
||||||
|
} else {
|
||||||
|
redirect.append("?").append(query).append("&code=").append(encodedCode);
|
||||||
|
}
|
||||||
|
redirect.append(encodedState);
|
||||||
|
|
||||||
|
result.setRedirectUrl(redirect.toString());
|
||||||
|
result.setSsoSessionId(resolvedSsoSessionId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String restoreSsoSessionIfNeeded(String ssoSessionId, String refreshToken) {
|
||||||
|
if (ssoSessionId != null && !ssoSessionId.isBlank()) {
|
||||||
|
return ssoSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginTokenVo loginTokenVo = authService.findUserTokenByRefreshToken(refreshToken);
|
||||||
|
if (loginTokenVo == null
|
||||||
|
|| loginTokenVo.getUserIdx() == null
|
||||||
|
|| loginTokenVo.getUserId() == null
|
||||||
|
|| loginTokenVo.getUserTokenIdx() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String restoredUserRole = loginTokenVo.getUserRole();
|
||||||
|
if (restoredUserRole == null || restoredUserRole.isBlank()) {
|
||||||
|
restoredUserRole = "USER";
|
||||||
|
}
|
||||||
|
|
||||||
|
String latestSessionKey = "alist:sso:userIdx:" + loginTokenVo.getUserIdx();
|
||||||
|
String existingSsoSessionId = redisTemplate.opsForValue().get(latestSessionKey);
|
||||||
|
if (existingSsoSessionId != null && !existingSsoSessionId.isBlank()) {
|
||||||
|
String existingSessionKey = "alist:sso:session:" + existingSsoSessionId;
|
||||||
|
String existingUserIdx = (String) redisTemplate.opsForHash().get(existingSessionKey, "userIdx");
|
||||||
|
if (existingUserIdx != null && !existingUserIdx.isBlank()) {
|
||||||
|
refreshSsoSessionTtl(existingSsoSessionId, existingUserIdx);
|
||||||
|
return existingSsoSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String restoredSsoSessionId = "SSO_" + UUID.randomUUID();
|
||||||
|
String sessionKey = "alist:sso:session:" + restoredSsoSessionId;
|
||||||
|
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userIdx", String.valueOf(loginTokenVo.getUserIdx()));
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userTokenIdx", String.valueOf(loginTokenVo.getUserTokenIdx()));
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userId", loginTokenVo.getUserId());
|
||||||
|
redisTemplate.opsForHash().put(sessionKey, "userRole", restoredUserRole);
|
||||||
|
redisTemplate.expire(sessionKey, ssoSessionTtl);
|
||||||
|
redisTemplate.opsForValue().set(latestSessionKey, restoredSsoSessionId, ssoSessionTtl);
|
||||||
|
|
||||||
|
return restoredSsoSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SsoExchangeDto exchange(SsoExchangeDto ssoExchangeDto) {
|
||||||
|
String codeKey = "alist:sso:code:" + ssoExchangeDto.getCode();
|
||||||
|
String ssoSessionId = (String) redisTemplate.opsForHash().get(codeKey, "ssoSessionId");
|
||||||
|
String clientId = (String) redisTemplate.opsForHash().get(codeKey, "clientId");
|
||||||
|
|
||||||
|
if (ssoSessionId == null || !ssoExchangeDto.getClientId().equals(clientId)) {
|
||||||
|
throw new IllegalArgumentException("유효하지 않은 code 입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionKey = "alist:sso:session:" + ssoSessionId;
|
||||||
|
String userId = (String) redisTemplate.opsForHash().get(sessionKey, "userId");
|
||||||
|
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
|
||||||
|
String userTokenIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userTokenIdx");
|
||||||
|
String userRole = (String) redisTemplate.opsForHash().get(sessionKey, "userRole");
|
||||||
|
|
||||||
|
String latestSsoSessionId = redisTemplate.opsForValue().get("alist:sso:userIdx:" + userIdx);
|
||||||
|
if (!ssoSessionId.equals(latestSsoSessionId)) {
|
||||||
|
throw new IllegalArgumentException("다른 곳에서 다시 로그인되어 세션이 만료되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSsoSessionTtl(ssoSessionId, userIdx);
|
||||||
|
redisTemplate.delete(codeKey);
|
||||||
|
|
||||||
|
SsoExchangeDto result = new SsoExchangeDto();
|
||||||
|
result.setUserId(userId);
|
||||||
|
result.setUserIdx(Integer.parseInt(userIdx));
|
||||||
|
result.setUserTokenIdx(Integer.parseInt(userTokenIdx));
|
||||||
|
result.setUserRole(userRole);
|
||||||
|
result.setSsoSessionId(ssoSessionId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logout(String ssoSessionId) {
|
||||||
|
if (ssoSessionId == null || ssoSessionId.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionKey = "alist:sso:session:" + ssoSessionId;
|
||||||
|
String userIdx = (String) redisTemplate.opsForHash().get(sessionKey, "userIdx");
|
||||||
|
|
||||||
|
redisTemplate.delete(sessionKey);
|
||||||
|
if (userIdx != null) {
|
||||||
|
redisTemplate.delete("alist:sso:userIdx:" + userIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshSsoSessionTtl(String ssoSessionId, String userIdx) {
|
||||||
|
if (ssoSessionId == null || ssoSessionId.isBlank() || userIdx == null || userIdx.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionKey = "alist:sso:session:" + ssoSessionId;
|
||||||
|
String latestSessionKey = "alist:sso:userIdx:" + userIdx;
|
||||||
|
|
||||||
|
redisTemplate.expire(sessionKey, ssoSessionTtl);
|
||||||
|
redisTemplate.expire(latestSessionKey, ssoSessionTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.alist.api.modules.auth.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class CorsOriginVo {
|
||||||
|
private String allowedOrigin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.alist.api.modules.auth.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class LoginTokenVo {
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private Integer userIdx;
|
||||||
|
private String userId;
|
||||||
|
private String userApiKey;
|
||||||
|
private String userRole;
|
||||||
|
private String refreshToken;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.alist.api.modules.auth.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class LoginVo {
|
||||||
|
private Integer userIdx;
|
||||||
|
private String id;
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.alist.api.modules.auth.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class SsoClientVo {
|
||||||
|
private Long ssoClientIdx;
|
||||||
|
private String clientId;
|
||||||
|
private String clientName;
|
||||||
|
private String redirectUri;
|
||||||
|
private String memo;
|
||||||
|
private String useYn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package com.alist.api.modules.file;
|
||||||
|
|
||||||
|
import com.alist.api.common.response.ApiResponse;
|
||||||
|
import com.alist.api.common.response.ApiResponseCode;
|
||||||
|
import com.alist.api.common.utils.SecurityUtil;
|
||||||
|
import com.alist.api.modules.file.dto.*;
|
||||||
|
import com.alist.api.modules.file.form.*;
|
||||||
|
import com.alist.api.modules.file.service.FileService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Tag(
|
||||||
|
name = "99. TUS 업로드"
|
||||||
|
, description = "TUS 기반 파일 업로드 초기화, 권한 검증, 상태 조회, 훅 처리, 취소 API"
|
||||||
|
)
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/files")
|
||||||
|
public class FileController {
|
||||||
|
private final FileService fileService;
|
||||||
|
|
||||||
|
public FileController(FileService fileService) {
|
||||||
|
this.fileService = fileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 업로드 초기화"
|
||||||
|
, description = "업로드 대상 파일 정보를 DB에 등록하고 fileUuid/uploadToken을 발급합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/uploadInit")
|
||||||
|
public ResponseEntity<ApiResponse<FileUploadDto>> uploadInit(
|
||||||
|
@Valid @RequestBody FileUploadForm fileUploadForm
|
||||||
|
) {
|
||||||
|
FileUploadDto fileUploadDto = fileUploadForm.toFileUploadDto();
|
||||||
|
|
||||||
|
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||||
|
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer userIdx = fileService.selectUserIdxByUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
|
if (userIdx == null) {
|
||||||
|
return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileUploadDto.setUserIdx(userIdx);
|
||||||
|
fileUploadDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
|
FileUploadDto fileUploadResult = fileService.insertFileInit(fileUploadDto);
|
||||||
|
|
||||||
|
return ApiResponse.entity(fileUploadResult, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "업로드 권한 검증"
|
||||||
|
, description = "Bearer 업로드 토큰과 fileUuid(및 TUS 메타데이터)를 검증해 업로드 가능 여부를 확인합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/uploadAuth")
|
||||||
|
public ResponseEntity<ApiResponse<String>> uploadAuth(
|
||||||
|
@RequestHeader(value = "Authorization", required = false) String uploadToken
|
||||||
|
, @RequestHeader(value = "x-File-Uuid", required = false) String fileUuid
|
||||||
|
, @RequestHeader(value = "Upload-Metadata", required = false) String uploadMetadata
|
||||||
|
, @RequestHeader(value = "Upload-Length", required = false) String uploadLength
|
||||||
|
) {
|
||||||
|
if (uploadToken == null || !uploadToken.startsWith("Bearer ")) {
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_401);
|
||||||
|
}
|
||||||
|
if (fileUuid == null || fileUuid.isBlank()) {
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_403);
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadTokenDto uploadTokenDto = new UploadTokenDto();
|
||||||
|
uploadTokenDto.setUploadToken(uploadToken.substring(7).trim());
|
||||||
|
uploadTokenDto.setFileUuid(fileUuid.trim());
|
||||||
|
uploadTokenDto.setUploadMetadataRaw(uploadMetadata);
|
||||||
|
uploadTokenDto.setUploadLengthRaw(uploadLength);
|
||||||
|
|
||||||
|
UploadTokenDto uploadTokenCheck = fileService.isUploadTokenCheck(uploadTokenDto);
|
||||||
|
|
||||||
|
if (uploadTokenCheck.getResultCode() == 401) {
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_401);
|
||||||
|
} else if (uploadTokenCheck.getResultCode() == 403) {
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "업로드 상태 조회"
|
||||||
|
, description = "fileUuid 기준으로 Redis/DB 업로드 상태(진행률, 상태값)를 조회합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/uploadStatus")
|
||||||
|
public ResponseEntity<ApiResponse<UploadStatusDto>> uploadStatus(
|
||||||
|
@Valid @RequestBody UploadStatusForm uploadStatusForm
|
||||||
|
) {
|
||||||
|
UploadStatusDto status = fileService.getUploadStatus(uploadStatusForm.toUploadStatusDto());
|
||||||
|
if (status == null) {
|
||||||
|
return ApiResponse.entity(new UploadStatusDto(), ApiResponseCode.CODE_2003);
|
||||||
|
}
|
||||||
|
return ApiResponse.entity(status, ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "TUS 훅 수신"
|
||||||
|
, description = "tusd에서 전달되는 pre-create/post-receive/post-finish/post-terminate 이벤트를 반영합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/tusHook")
|
||||||
|
public ResponseEntity<ApiResponse<String>> tusHook(@RequestBody TusHookForm tusHookForm) {
|
||||||
|
boolean accepted = fileService.updateFileUploadStatus(tusHookForm.toTusHookDto());
|
||||||
|
|
||||||
|
if (accepted) {
|
||||||
|
fileService.saveUploadStatusRedisTusHook(tusHookForm.toTusHookDto());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "업로드 취소"
|
||||||
|
, description = "현재 로그인 사용자의 fileUuid 업로드를 취소(CANCELED) 처리합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/uploadCancel")
|
||||||
|
public ResponseEntity<ApiResponse<String>> uploadCancel(
|
||||||
|
@Valid @RequestBody UploadCancelForm uploadCancelForm
|
||||||
|
) {
|
||||||
|
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||||
|
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_401);
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadCancelDto uploadCancelDto = new UploadCancelDto();
|
||||||
|
|
||||||
|
uploadCancelDto.setFileUuid(uploadCancelForm.getFileUuid().trim());
|
||||||
|
uploadCancelDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
|
boolean canceled = fileService.updateUploadCancel(uploadCancelDto);
|
||||||
|
|
||||||
|
if (!canceled) {
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "업로드 취소");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 목록 조회"
|
||||||
|
, description = "FILE_MASTER_IDX 기준으로 완료된 파일 목록을 조회합니다.")
|
||||||
|
@GetMapping("/list/{fileMasterIdx}")
|
||||||
|
public ResponseEntity<ApiResponse<FileDownloadListDto>> fileListByFileMasterIdx(
|
||||||
|
@PathVariable Long fileMasterIdx
|
||||||
|
) {
|
||||||
|
|
||||||
|
FileDownloadDto fileDownloadDto = new FileDownloadDto();
|
||||||
|
fileDownloadDto.setFileMasterIdx(fileMasterIdx);
|
||||||
|
|
||||||
|
FileDownloadListDto fileDownloadList = fileService.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
|
||||||
|
|
||||||
|
if (fileDownloadList.getResultCode() == 401) {
|
||||||
|
return ApiResponse.entity(fileDownloadList, ApiResponseCode.CODE_401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity(fileDownloadList, ApiResponseCode.CODE_2001, "파일");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 보기"
|
||||||
|
, description = "권한 확인 후 Nginx X-Accel-Redirect로 파일을 inline 조회합니다.")
|
||||||
|
@GetMapping("/view/{fileUuid}")
|
||||||
|
public ResponseEntity<Resource> fileView(
|
||||||
|
@PathVariable String fileUuid
|
||||||
|
, HttpServletRequest request
|
||||||
|
) throws IOException {
|
||||||
|
FileDownloadDto fileDownloadDto = new FileDownloadDto();
|
||||||
|
fileDownloadDto.setFileUuid(fileUuid);
|
||||||
|
fileDownloadDto.setEventType("VIEW");
|
||||||
|
fileDownloadDto.setClientIp(request.getRemoteAddr());
|
||||||
|
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
|
||||||
|
fileDownloadDto.setReferer(request.getHeader("Referer"));
|
||||||
|
|
||||||
|
FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto);
|
||||||
|
|
||||||
|
if (target == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path filePath = fileService.resolveStoredFilePath(target.getSavePath());
|
||||||
|
|
||||||
|
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource resource = new InputStreamResource(Files.newInputStream(filePath));
|
||||||
|
String contentType = target.getContentType() == null
|
||||||
|
? "application/octet-stream"
|
||||||
|
: target.getContentType();
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(contentType))
|
||||||
|
.contentLength(Files.size(filePath))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
ContentDisposition.inline()
|
||||||
|
.filename(target.getOriginName(), StandardCharsets.UTF_8)
|
||||||
|
.build()
|
||||||
|
.toString())
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 다운로드"
|
||||||
|
, description = "권한 확인 후 파일 다운로드 로그를 남기고 redirect 또는 Nginx X-Accel-Redirect로 파일 다운로드 합니다.")
|
||||||
|
@GetMapping("/download/{fileUuid}")
|
||||||
|
public ResponseEntity<Resource> fileDownload(
|
||||||
|
@PathVariable String fileUuid
|
||||||
|
, HttpServletRequest request
|
||||||
|
) throws IOException {
|
||||||
|
FileDownloadDto fileDownloadDto = new FileDownloadDto();
|
||||||
|
fileDownloadDto.setFileUuid(fileUuid);
|
||||||
|
fileDownloadDto.setEventType("DOWNLOAD");
|
||||||
|
fileDownloadDto.setClientIp(request.getRemoteAddr());
|
||||||
|
fileDownloadDto.setUserAgent(request.getHeader("User-Agent"));
|
||||||
|
fileDownloadDto.setReferer(request.getHeader("Referer"));
|
||||||
|
|
||||||
|
FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto);
|
||||||
|
|
||||||
|
if (target == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path filePath = fileService.resolveStoredFilePath(target.getSavePath());
|
||||||
|
|
||||||
|
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource resource = new InputStreamResource(Files.newInputStream(filePath));
|
||||||
|
String contentType = target.getContentType() == null
|
||||||
|
? "application/octet-stream"
|
||||||
|
: target.getContentType();
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(contentType))
|
||||||
|
.contentLength(Files.size(filePath))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
ContentDisposition.attachment()
|
||||||
|
.filename(target.getOriginName(), StandardCharsets.UTF_8)
|
||||||
|
.build()
|
||||||
|
.toString())
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "파일 삭제 처리",
|
||||||
|
description = "fileUuid 기준으로 완료된 파일(STATUS = 3)을 논리 삭제 처리합니다. 실제 파일은 삭제하지 않고 DEL_YN = 'Y'만 반영합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/delete")
|
||||||
|
public ResponseEntity<ApiResponse<String>> fileDelete(
|
||||||
|
@Valid @RequestBody FileDeleteForm fileDeleteForm
|
||||||
|
) {
|
||||||
|
boolean deleted = fileService.updateFileDelete(fileDeleteForm.toFileDeleteDto());
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_2005, "파일 삭제");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity("", ApiResponseCode.CODE_200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileDeleteDto {
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private String fileUuid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileDownloadDto {
|
||||||
|
private Long fileDetailIdx;
|
||||||
|
private Long fileMasterIdx;
|
||||||
|
private String fileUuid;
|
||||||
|
private String originName;
|
||||||
|
private String contentType;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String savePath;
|
||||||
|
|
||||||
|
private Integer userIdx;
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private String eventType;
|
||||||
|
private String clientIp;
|
||||||
|
private String userAgent;
|
||||||
|
private String referer;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer resultCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileDownloadItemDto {
|
||||||
|
private Long fileDetailIdx;
|
||||||
|
private Long fileMasterIdx;
|
||||||
|
private String fileUuid;
|
||||||
|
private String originName;
|
||||||
|
private String contentType;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String viewUrl;
|
||||||
|
private String downloadUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileDownloadListDto {
|
||||||
|
private Long fileMasterIdx;
|
||||||
|
private List<FileDownloadItemDto> itemList;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer resultCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileMoveTaskDto {
|
||||||
|
private Long fileDetailIdx;
|
||||||
|
private Long fileMasterIdx;
|
||||||
|
private String fileUuid;
|
||||||
|
private String tusUploadId;
|
||||||
|
private String originName;
|
||||||
|
private String contentType;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private Long uploadedBytes;
|
||||||
|
|
||||||
|
private String folderPath;
|
||||||
|
|
||||||
|
private String saveName;
|
||||||
|
private String savePath;
|
||||||
|
private String ext;
|
||||||
|
|
||||||
|
private String moveYn;
|
||||||
|
private Integer moveTryCount;
|
||||||
|
private String moveLastError;
|
||||||
|
private String moveLockOwner;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileUploadDto {
|
||||||
|
private Long fileMasterIdx;
|
||||||
|
private String tusEndpoint;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private String fileCategory;
|
||||||
|
@JsonIgnore
|
||||||
|
private String folderPath;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer userIdx;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer status;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer totalCount;
|
||||||
|
|
||||||
|
private List<FileUploadItemDto> itemList;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileUploadItemDto {
|
||||||
|
private Integer fileSeq;
|
||||||
|
private String fileUuid;
|
||||||
|
private String uploadToken;
|
||||||
|
private String originName;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private Long fileMasterIdx;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer userIdx;
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileUploadMetaDto {
|
||||||
|
private String fileUuid;
|
||||||
|
private String originName;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String contentType;
|
||||||
|
private String tusUploadId;
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class TusHookDto {
|
||||||
|
private String type;
|
||||||
|
private String uploadId;
|
||||||
|
private Long size;
|
||||||
|
private Long offset;
|
||||||
|
private String fileUuid;
|
||||||
|
private String filename;
|
||||||
|
private String filetype;
|
||||||
|
private Long uploadedBytes;
|
||||||
|
private Integer status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class UploadCancelDto {
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private String fileUuid;
|
||||||
|
|
||||||
|
private Long uploadedBytes;
|
||||||
|
private Long sizeBytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class UploadStatusDto {
|
||||||
|
private String fileUuid;
|
||||||
|
private String status; // PENDING, UPLOADING, DONE, FAILED
|
||||||
|
private Long uploadedBytes;
|
||||||
|
private Long totalBytes;
|
||||||
|
private Integer percent;
|
||||||
|
private String updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.alist.api.modules.file.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class UploadTokenDto {
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private String fileUuid;
|
||||||
|
private String uploadToken;
|
||||||
|
|
||||||
|
private String uploadMetadataRaw;
|
||||||
|
private String uploadLengthRaw;
|
||||||
|
|
||||||
|
private String originName;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private Integer resultCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.dto.FileDeleteDto;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileDeleteForm {
|
||||||
|
@NotBlank
|
||||||
|
private String fileUuid;
|
||||||
|
|
||||||
|
public FileDeleteDto toFileDeleteDto() {
|
||||||
|
FileDeleteDto fileDeleteDto = new FileDeleteDto();
|
||||||
|
fileDeleteDto.setFileUuid(fileUuid == null ? null : fileUuid.trim());
|
||||||
|
return fileDeleteDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.dto.FileUploadDto;
|
||||||
|
import com.alist.api.modules.file.dto.FileUploadItemDto;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileUploadForm {
|
||||||
|
@NotBlank
|
||||||
|
private String fileCategory;
|
||||||
|
|
||||||
|
private String folderPath;
|
||||||
|
|
||||||
|
@NotEmpty
|
||||||
|
@Valid
|
||||||
|
private List<FileUploadItemForm> itemList;
|
||||||
|
|
||||||
|
public FileUploadDto toFileUploadDto() {
|
||||||
|
FileUploadDto fileUploadDto = new FileUploadDto();
|
||||||
|
|
||||||
|
fileUploadDto.setFileCategory(this.fileCategory.trim());
|
||||||
|
fileUploadDto.setFolderPath((this.folderPath == null || this.folderPath.isBlank()) ? "/" : this.folderPath.trim());
|
||||||
|
|
||||||
|
List<FileUploadItemDto> fileUploadItemDtoList = new ArrayList<>();
|
||||||
|
|
||||||
|
int i = 1;
|
||||||
|
for (FileUploadItemForm item : this.itemList) {
|
||||||
|
FileUploadItemDto fileUploadItemDto = new FileUploadItemDto();
|
||||||
|
|
||||||
|
fileUploadItemDto.setFileSeq(i++);
|
||||||
|
fileUploadItemDto.setOriginName(item.getOriginName());
|
||||||
|
fileUploadItemDto.setSizeBytes(item.getSizeBytes());
|
||||||
|
fileUploadItemDto.setContentType(item.getContentType());
|
||||||
|
|
||||||
|
fileUploadItemDtoList.add(fileUploadItemDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileUploadDto.setItemList(fileUploadItemDtoList);
|
||||||
|
|
||||||
|
return fileUploadDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FileUploadItemForm {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String originName;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
|
private Long sizeBytes;
|
||||||
|
|
||||||
|
private String contentType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class TusHookEventForm {
|
||||||
|
@JsonProperty("Upload")
|
||||||
|
private TusHookUploadForm tusHookUploadForm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.dto.TusHookDto;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class TusHookForm {
|
||||||
|
@JsonProperty("Type")
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@JsonProperty("Event")
|
||||||
|
private TusHookEventForm tusHookEventForm;
|
||||||
|
|
||||||
|
public TusHookDto toTusHookDto() {
|
||||||
|
TusHookDto tusHookDto = new TusHookDto();
|
||||||
|
|
||||||
|
tusHookDto.setType(this.type);
|
||||||
|
|
||||||
|
if (this.tusHookEventForm == null || this.tusHookEventForm.getTusHookUploadForm() == null) {
|
||||||
|
return tusHookDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
TusHookUploadForm upload = this.tusHookEventForm.getTusHookUploadForm();
|
||||||
|
tusHookDto.setUploadId(upload.getId());
|
||||||
|
tusHookDto.setSize(upload.getSize());
|
||||||
|
tusHookDto.setOffset(upload.getOffset());
|
||||||
|
|
||||||
|
if (upload.getMetaData() != null) {
|
||||||
|
tusHookDto.setFileUuid(upload.getMetaData().get("fileUuid"));
|
||||||
|
tusHookDto.setFilename(upload.getMetaData().get("filename"));
|
||||||
|
tusHookDto.setFiletype(upload.getMetaData().get("filetype"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tusHookDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class TusHookUploadForm {
|
||||||
|
@JsonProperty("ID")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@JsonProperty("Size")
|
||||||
|
private Long size;
|
||||||
|
|
||||||
|
@JsonProperty("Offset")
|
||||||
|
private Long offset;
|
||||||
|
|
||||||
|
@JsonProperty("MetaData")
|
||||||
|
private Map<String, String> metaData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class UploadCancelForm {
|
||||||
|
@NotBlank
|
||||||
|
private String fileUuid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.alist.api.modules.file.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.dto.UploadStatusDto;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class UploadStatusForm {
|
||||||
|
@NotBlank
|
||||||
|
private String fileUuid;
|
||||||
|
|
||||||
|
public UploadStatusDto toUploadStatusDto() {
|
||||||
|
UploadStatusDto uploadStatusDto = new UploadStatusDto();
|
||||||
|
uploadStatusDto.setFileUuid(this.fileUuid == null ? null : this.fileUuid.trim());
|
||||||
|
return uploadStatusDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.alist.api.modules.file.mapper;
|
||||||
|
|
||||||
|
import com.alist.api.modules.file.dto.*;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface FileMapper {
|
||||||
|
void insertFileMasterInit(FileUploadDto fileUploadDto);
|
||||||
|
|
||||||
|
void insertFileDetailInit(FileUploadItemDto item);
|
||||||
|
|
||||||
|
int selectFileDetailCountByFileUuidUserToKenIdx(UploadTokenDto uploadTokenDto);
|
||||||
|
|
||||||
|
void insertFileUploadEventLog(TusHookDto tusHookDto);
|
||||||
|
|
||||||
|
void updateFileDetailStatus(TusHookDto tusHookDto);
|
||||||
|
|
||||||
|
void updateFileMasterAggregateByFileUuid(String fileUuid);
|
||||||
|
|
||||||
|
FileUploadMetaDto selectFileUploadMetaByFileUuid(String fileUuid);
|
||||||
|
|
||||||
|
void updateTusUploadIdIfNull(FileUploadMetaDto fileUploadMetaDto);
|
||||||
|
|
||||||
|
void updateFileDetailCanceledByFileUuid(String fileUuid);
|
||||||
|
|
||||||
|
int selectFileDetailCountByFileUuidAndUserToKenIdx(UploadCancelDto uploadCancelDto);
|
||||||
|
|
||||||
|
int updateFileDetailCanceledByFileUuidAndUserTokenIdx(UploadCancelDto uploadCancelDto);
|
||||||
|
|
||||||
|
UploadCancelDto selectUploadProgressByFileUuid(UploadCancelDto uploadCancelDto);
|
||||||
|
|
||||||
|
FileMoveTaskDto selectMoveTaskByFileUuid(String fileUuid);
|
||||||
|
|
||||||
|
int updateFileMoveSuccess(FileMoveTaskDto task);
|
||||||
|
|
||||||
|
int updateFileMovePendingByFileUuid(FileMoveTaskDto task);
|
||||||
|
|
||||||
|
List<FileDownloadItemDto> selectFileDownloadListByFileMasterIdx(FileDownloadDto fileDownloadDto);
|
||||||
|
|
||||||
|
FileDownloadDto selectFileDetailByFileUuid(FileDownloadDto fileDownloadDto);
|
||||||
|
|
||||||
|
void insertFileDownloadEventLog(FileDownloadDto fileDetailInfo);
|
||||||
|
|
||||||
|
int selectFileDeleteTargetCount(FileDeleteDto fileDeleteDto);
|
||||||
|
|
||||||
|
int updateFileDetailDeleteByFileUuid(FileDeleteDto fileDeleteDto);
|
||||||
|
|
||||||
|
int updateFileMasterDeleteByFileUuid(FileDeleteDto fileDeleteDto);
|
||||||
|
|
||||||
|
Integer selectUserIdxByUserTokenIdx(Integer userTokenIdx);
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
package com.alist.api.modules.file.service;
|
||||||
|
|
||||||
|
import com.alist.api.common.utils.SecurityUtil;
|
||||||
|
import com.alist.api.config.jwt.JwtTokenProvider;
|
||||||
|
import com.alist.api.modules.file.dto.*;
|
||||||
|
import com.alist.api.modules.file.mapper.FileMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.HashOperations;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class FileService {
|
||||||
|
@Value("${file.upload.tus-endpoint}")
|
||||||
|
private String tusEndpoint;
|
||||||
|
|
||||||
|
@Value("${file.upload.public-base-url}")
|
||||||
|
private String publicBaseUrl;
|
||||||
|
|
||||||
|
@Value("${file.upload.auth-cache.ttl-seconds:20}")
|
||||||
|
private long uploadAuthCacheTtlSeconds;
|
||||||
|
|
||||||
|
@Value("${file.upload.interrupt-seconds:30}")
|
||||||
|
private long uploadInterruptSeconds;
|
||||||
|
|
||||||
|
@Value("${file.upload.tmp-root}")
|
||||||
|
private String uploadTmpRoot;
|
||||||
|
|
||||||
|
@Value("${file.upload.final-root}")
|
||||||
|
private String uploadFinalRoot;
|
||||||
|
|
||||||
|
private final FileMapper fileMapper;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
|
|
||||||
|
public FileService(FileMapper fileMapper, JwtTokenProvider jwtTokenProvider, StringRedisTemplate stringRedisTemplate) {
|
||||||
|
this.fileMapper = fileMapper;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
this.stringRedisTemplate = stringRedisTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*파일 초기화 작업*/
|
||||||
|
@Transactional
|
||||||
|
public FileUploadDto insertFileInit(FileUploadDto fileUploadDto) {
|
||||||
|
fileUploadDto.setStatus(0);
|
||||||
|
fileUploadDto.setTotalCount(fileUploadDto.getItemList().size());
|
||||||
|
|
||||||
|
fileMapper.insertFileMasterInit(fileUploadDto);
|
||||||
|
fileUploadDto.setTusEndpoint(tusEndpoint);
|
||||||
|
|
||||||
|
if (fileUploadDto.getItemList() == null || fileUploadDto.getItemList().isEmpty()) {
|
||||||
|
return fileUploadDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (FileUploadItemDto item : fileUploadDto.getItemList()) {
|
||||||
|
item.setFileMasterIdx(fileUploadDto.getFileMasterIdx());
|
||||||
|
item.setFileUuid(UUID.randomUUID().toString());
|
||||||
|
item.setUploadToken(jwtTokenProvider.createUploadToken(fileUploadDto.getUserTokenIdx()));
|
||||||
|
item.setUserIdx(fileUploadDto.getUserIdx());
|
||||||
|
item.setStatus(0); // PENDING
|
||||||
|
fileMapper.insertFileDetailInit(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileUploadDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*업로드 권한 체크*/
|
||||||
|
public UploadTokenDto isUploadTokenCheck(UploadTokenDto uploadTokenDto) {
|
||||||
|
if (!jwtTokenProvider.validateToken(uploadTokenDto.getUploadToken())) {
|
||||||
|
uploadTokenDto.setResultCode(401);
|
||||||
|
return uploadTokenDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jwtTokenProvider.hasUploadScope(uploadTokenDto.getUploadToken())) {
|
||||||
|
uploadTokenDto.setResultCode(403);
|
||||||
|
return uploadTokenDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uploadTokenDto.setUserTokenIdx(Integer.parseInt(jwtTokenProvider.getUserTokenIdx(uploadTokenDto.getUploadToken())));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
uploadTokenDto.setResultCode(401);
|
||||||
|
return uploadTokenDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTusMeta(uploadTokenDto);
|
||||||
|
|
||||||
|
String cacheKey = buildAuthCacheKey(uploadTokenDto);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String cached = stringRedisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
try {
|
||||||
|
uploadTokenDto.setResultCode(Integer.parseInt(cached));
|
||||||
|
return uploadTokenDto;
|
||||||
|
} catch (NumberFormatException parseEx) {
|
||||||
|
log.warn("uploadAuth cache parse fail. key={}, value={}", cacheKey, cached);
|
||||||
|
stringRedisTemplate.delete(cacheKey); // 오염 캐시 정리
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception redisGetEx) {
|
||||||
|
log.warn("uploadAuth cache get fail. fallback DB. key={}", cacheKey, redisGetEx);
|
||||||
|
}
|
||||||
|
|
||||||
|
int isOk = fileMapper.selectFileDetailCountByFileUuidUserToKenIdx(uploadTokenDto);
|
||||||
|
int resultCode = (isOk > 0) ? 200 : 403;
|
||||||
|
uploadTokenDto.setResultCode(resultCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
stringRedisTemplate.opsForValue().set(
|
||||||
|
cacheKey,
|
||||||
|
String.valueOf(resultCode),
|
||||||
|
Duration.ofSeconds(safeTtlSeconds())
|
||||||
|
);
|
||||||
|
} catch (Exception redisSetEx) {
|
||||||
|
log.warn("uploadAuth cache set fail. key={}, resultCode={}", cacheKey, resultCode, redisSetEx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploadTokenDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyTusMeta(UploadTokenDto dto) {
|
||||||
|
String len = dto.getUploadLengthRaw();
|
||||||
|
if (len != null && !len.isBlank()) {
|
||||||
|
try { dto.setSizeBytes(Long.parseLong(len.trim())); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String raw = dto.getUploadMetadataRaw();
|
||||||
|
if (raw == null || raw.isBlank()) return;
|
||||||
|
|
||||||
|
for (String part : raw.split(",")) {
|
||||||
|
String p = part.trim();
|
||||||
|
int sp = p.indexOf(' ');
|
||||||
|
if (sp <= 0) continue;
|
||||||
|
|
||||||
|
String key = p.substring(0, sp).trim();
|
||||||
|
String val = p.substring(sp + 1).trim();
|
||||||
|
String decoded;
|
||||||
|
try {
|
||||||
|
decoded = new String(java.util.Base64.getDecoder().decode(val), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("filename".equals(key)) dto.setOriginName(decoded);
|
||||||
|
else if ("filetype".equals(key)) dto.setContentType(decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UploadStatusDto getUploadStatus(UploadStatusDto uploadStatusDto) {
|
||||||
|
String key = buildUploadStatusKey(uploadStatusDto.getFileUuid());
|
||||||
|
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(key);
|
||||||
|
|
||||||
|
if (map.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileUuid = (String) map.getOrDefault("fileUuid", uploadStatusDto.getFileUuid());
|
||||||
|
String status = (String) map.getOrDefault("status", "PENDING");
|
||||||
|
long uploadedBytes = parseLong(map.get("uploadedBytes"));
|
||||||
|
long totalBytes = parseLong(map.get("totalBytes"));
|
||||||
|
int percent = (int) parseLong(map.get("percent"));
|
||||||
|
String updatedAt = (String) map.getOrDefault("updatedAt", "");
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (isStaleUploading(status, updatedAt)) {
|
||||||
|
status = "CANCELED";
|
||||||
|
updatedAt = Instant.now().toString();
|
||||||
|
|
||||||
|
saveCanceledStatusRedis(fileUuid, uploadedBytes, totalBytes);
|
||||||
|
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
|
||||||
|
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
uploadStatusDto.setFileUuid(fileUuid);
|
||||||
|
uploadStatusDto.setStatus(status);
|
||||||
|
uploadStatusDto.setUploadedBytes(uploadedBytes);
|
||||||
|
uploadStatusDto.setTotalBytes(totalBytes);
|
||||||
|
uploadStatusDto.setPercent(percent);
|
||||||
|
uploadStatusDto.setUpdatedAt(updatedAt);
|
||||||
|
|
||||||
|
return uploadStatusDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStaleUploading(String status, String updatedAt) {
|
||||||
|
if (!"UPLOADING".equalsIgnoreCase(status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (updatedAt == null || updatedAt.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Instant lastUpdatedAt = Instant.parse(updatedAt);
|
||||||
|
long elapsedSeconds = Duration.between(lastUpdatedAt, Instant.now()).getSeconds();
|
||||||
|
return elapsedSeconds >= safeInterruptSeconds();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("upload status updatedAt parse fail. updatedAt={}", updatedAt, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long safeInterruptSeconds() {
|
||||||
|
return (uploadInterruptSeconds > 0) ? uploadInterruptSeconds : 30L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*db에 상태값 저장*/
|
||||||
|
@Transactional
|
||||||
|
public boolean updateFileUploadStatus(TusHookDto tusHookDto) {
|
||||||
|
if (tusHookDto == null || tusHookDto.getFileUuid() == null || tusHookDto.getFileUuid().isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileUuid = tusHookDto.getFileUuid().trim();
|
||||||
|
String type = tusHookDto.getType() == null ? "" : tusHookDto.getType().trim().toLowerCase();
|
||||||
|
String uploadId = tusHookDto.getUploadId() == null ? "" : tusHookDto.getUploadId().trim();
|
||||||
|
long offset = tusHookDto.getOffset() == null ? 0L : tusHookDto.getOffset();
|
||||||
|
long size = tusHookDto.getSize() == null ? 0L : tusHookDto.getSize();
|
||||||
|
|
||||||
|
fileMapper.insertFileUploadEventLog(tusHookDto);
|
||||||
|
|
||||||
|
FileUploadMetaDto fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
|
||||||
|
if (fileUploadMetaInfo == null) return true;
|
||||||
|
|
||||||
|
if (isMetadataMismatch(fileUploadMetaInfo, tusHookDto)) {
|
||||||
|
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
|
||||||
|
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
|
||||||
|
saveCanceledStatusRedis(fileUuid, offset, size);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploadId.isBlank()) {
|
||||||
|
if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank()) {
|
||||||
|
FileUploadMetaDto fileUploadMetaDto = new FileUploadMetaDto();
|
||||||
|
fileUploadMetaDto.setFileUuid(fileUuid);
|
||||||
|
fileUploadMetaDto.setTusUploadId(uploadId);
|
||||||
|
fileMapper.updateTusUploadIdIfNull(fileUploadMetaDto);
|
||||||
|
|
||||||
|
fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid);
|
||||||
|
}
|
||||||
|
if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank()
|
||||||
|
|| !fileUploadMetaInfo.getTusUploadId().equals(uploadId)) {
|
||||||
|
fileMapper.updateFileDetailCanceledByFileUuid(fileUuid);
|
||||||
|
fileMapper.updateFileMasterAggregateByFileUuid(fileUuid);
|
||||||
|
saveCanceledStatusRedis(fileUuid, offset, size);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//tusHookDto.setUploadedBytes(offset); // 전체 사이즈 업데이트 삭제
|
||||||
|
|
||||||
|
Integer currentStatus = fileUploadMetaInfo.getStatus();
|
||||||
|
if (currentStatus != null && (currentStatus == 3 || currentStatus == 5)) {
|
||||||
|
String t = (type == null) ? "" : type;
|
||||||
|
if (!"post-finish".equals(t) && !"post-terminate".equals(t)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "pre-create":
|
||||||
|
// 이미 진행 이상이면 되돌리지 않음
|
||||||
|
if (currentStatus != null && currentStatus >= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tusHookDto.setUploadedBytes(null);
|
||||||
|
tusHookDto.setStatus(1); // UPLOADING
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "post-receive":
|
||||||
|
// DONE/CANCELED 이후 늦게 온 receive 이벤트 무시
|
||||||
|
if (currentStatus != null && (currentStatus == 3 || currentStatus == 5)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tusHookDto.setUploadedBytes(null);
|
||||||
|
tusHookDto.setStatus(1); // UPLOADING
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "post-finish":
|
||||||
|
tusHookDto.setUploadedBytes(size);
|
||||||
|
tusHookDto.setStatus(3); // DONE
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "post-terminate":
|
||||||
|
// 이미 DONE이면 terminate 무시 (완료 우선)
|
||||||
|
if (currentStatus != null && currentStatus == 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tusHookDto.setUploadedBytes(offset);
|
||||||
|
tusHookDto.setStatus(5); // CANCELED
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tusHookDto.getStatus() == 1 && currentStatus != null && currentStatus >= 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean shouldUpdateDetail = !"post-receive".equals(type);
|
||||||
|
|
||||||
|
if (shouldUpdateDetail) {
|
||||||
|
fileMapper.updateFileDetailStatus(tusHookDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("post-finish".equals(type) || "post-terminate".equals(type)) {
|
||||||
|
fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("post-finish".equals(type)) {
|
||||||
|
try {
|
||||||
|
tryMoveNowByFileUuid(tusHookDto.getFileUuid());
|
||||||
|
} catch (NoSuchFileException e) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(200);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
tryMoveNowByFileUuid(tusHookDto.getFileUuid()); // 1회 재시도
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Move failed after retry. fileUuid={}", tusHookDto.getFileUuid(), ex);
|
||||||
|
markMovePending(tusHookDto.getFileUuid(), truncateErr(ex.toString()));
|
||||||
|
}
|
||||||
|
} catch (Exception moveEx) {
|
||||||
|
log.error("Move failed. fileUuid={}", tusHookDto.getFileUuid(), moveEx);
|
||||||
|
markMovePending(tusHookDto.getFileUuid(), truncateErr(moveEx.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void tryMoveNowByFileUuid(String fileUuid) throws IOException {
|
||||||
|
FileMoveTaskDto task = fileMapper.selectMoveTaskByFileUuid(fileUuid);
|
||||||
|
if (task == null) return;
|
||||||
|
if (task.getMoveYn() != null && !"N".equalsIgnoreCase(task.getMoveYn())) return;
|
||||||
|
|
||||||
|
Path tmpPath = resolveTmpPath(task);
|
||||||
|
Path finalPath = buildFinalPath(task);
|
||||||
|
Files.createDirectories(finalPath.getParent());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.move(tmpPath, finalPath, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (AtomicMoveNotSupportedException ex) {
|
||||||
|
Files.copy(tmpPath, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
Files.deleteIfExists(tmpPath);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Move failed. fileUuid={}, tmpPath={}, finalPath={}", fileUuid, tmpPath, finalPath, ex);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.setSavePath(toSavePath(finalPath));
|
||||||
|
task.setSaveName(finalPath.getFileName().toString());
|
||||||
|
task.setExt(extractExt(task.getSaveName()));
|
||||||
|
fileMapper.updateFileMoveSuccess(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void markMovePending(String fileUuid, String errMsg) {
|
||||||
|
FileMoveTaskDto task = new FileMoveTaskDto();
|
||||||
|
task.setFileUuid(fileUuid);
|
||||||
|
task.setMoveLastError(errMsg == null ? "move failed" : errMsg);
|
||||||
|
fileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncateErr(String s) {
|
||||||
|
if (s == null) return "unknown";
|
||||||
|
return s.length() > 500 ? s.substring(0, 500) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path resolveTmpPath(FileMoveTaskDto task) {
|
||||||
|
// 예: /srv/project/alist/uploads/tmp/{tusUploadId}
|
||||||
|
if (task.getTusUploadId() == null || task.getTusUploadId().isBlank()) {
|
||||||
|
throw new IllegalStateException("tusUploadId is empty");
|
||||||
|
}
|
||||||
|
return Paths.get(uploadTmpRoot, task.getTusUploadId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path buildFinalPath(FileMoveTaskDto task) {
|
||||||
|
String ext = extractExt(task.getOriginName());
|
||||||
|
String fileName = (task.getFileUuid() == null ? UUID.randomUUID().toString() : task.getFileUuid())
|
||||||
|
+ (ext.isBlank() ? "" : "." + ext);
|
||||||
|
|
||||||
|
LocalDate now = LocalDate.now();
|
||||||
|
String folder = normalizeFolderPath(task.getFolderPath());
|
||||||
|
|
||||||
|
return Paths.get(uploadFinalRoot,
|
||||||
|
folder,
|
||||||
|
String.valueOf(now.getYear()),
|
||||||
|
String.format("%02d", now.getMonthValue()),
|
||||||
|
String.format("%02d", now.getDayOfMonth()),
|
||||||
|
fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeFolderPath(String folderPath) {
|
||||||
|
if (folderPath == null || folderPath.isBlank() || "/".equals(folderPath.trim())) {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
String s = folderPath.trim().replace("\\", "/");
|
||||||
|
|
||||||
|
// 앞뒤 슬래시 제거
|
||||||
|
while (s.startsWith("/")) s = s.substring(1);
|
||||||
|
while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
|
||||||
|
|
||||||
|
// 경로 역참조 차단
|
||||||
|
s = s.replace("..", "");
|
||||||
|
|
||||||
|
// 비어버리면 기본 폴더
|
||||||
|
if (s.isBlank()) return "default";
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toSavePath(Path finalPath) {
|
||||||
|
// DB 저장용 상대경로(운영 규칙에 맞게 조정)
|
||||||
|
// 예: /2026/03/09/xxx.zip
|
||||||
|
Path root = Paths.get(uploadFinalRoot);
|
||||||
|
Path rel = root.relativize(finalPath);
|
||||||
|
return "/" + rel.toString().replace("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractExt(String name) {
|
||||||
|
if (name == null) return "";
|
||||||
|
int idx = name.lastIndexOf('.');
|
||||||
|
if (idx < 0 || idx == name.length() - 1) return "";
|
||||||
|
return name.substring(idx + 1).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*레이디스에 상태값 저장*/
|
||||||
|
public void saveUploadStatusRedisTusHook(TusHookDto tusHookDto) {
|
||||||
|
if (tusHookDto == null || tusHookDto.getFileUuid() == null || tusHookDto.getFileUuid().isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String key = buildUploadStatusKey(tusHookDto.getFileUuid().trim());
|
||||||
|
String type = tusHookDto.getType() == null ? "" : tusHookDto.getType().trim().toLowerCase();
|
||||||
|
long total = tusHookDto.getSize() == null ? 0L : tusHookDto.getSize();
|
||||||
|
long uploaded = tusHookDto.getOffset() == null ? 0L : tusHookDto.getOffset();
|
||||||
|
|
||||||
|
String status = "UPLOADING";
|
||||||
|
if ("post-finish".equals(type)) {
|
||||||
|
status = "DONE";
|
||||||
|
uploaded = total;
|
||||||
|
} else if ("post-terminate".equals(type)) {
|
||||||
|
status = "CANCELED";
|
||||||
|
} else if ("pre-create".equals(type)) {
|
||||||
|
status = "PENDING";
|
||||||
|
}
|
||||||
|
|
||||||
|
int percent = (total > 0) ? (int) Math.min(100, (uploaded * 100) / total) : 0;
|
||||||
|
|
||||||
|
HashOperations<String, Object, Object> ops = stringRedisTemplate.opsForHash();
|
||||||
|
ops.put(key, "fileUuid", tusHookDto.getFileUuid());
|
||||||
|
ops.put(key, "status", status);
|
||||||
|
ops.put(key, "uploadedBytes", String.valueOf(uploaded));
|
||||||
|
ops.put(key, "totalBytes", String.valueOf(total));
|
||||||
|
ops.put(key, "percent", String.valueOf(percent));
|
||||||
|
ops.put(key, "updatedAt", Instant.now().toString());
|
||||||
|
|
||||||
|
// 복원용으로 하루 유지
|
||||||
|
stringRedisTemplate.expire(key, Duration.ofHours(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveCanceledStatusRedis(String fileUuid, long uploadedBytes, long totalBytes) {
|
||||||
|
String key = buildUploadStatusKey(fileUuid);
|
||||||
|
int percent = (totalBytes > 0) ? (int) Math.min(100, (uploadedBytes * 100) / totalBytes) : 0;
|
||||||
|
|
||||||
|
HashOperations<String, Object, Object> ops = stringRedisTemplate.opsForHash();
|
||||||
|
ops.put(key, "fileUuid", fileUuid);
|
||||||
|
ops.put(key, "status", "CANCELED");
|
||||||
|
ops.put(key, "uploadedBytes", String.valueOf(Math.max(0, uploadedBytes)));
|
||||||
|
ops.put(key, "totalBytes", String.valueOf(Math.max(0, totalBytes)));
|
||||||
|
ops.put(key, "percent", String.valueOf(percent));
|
||||||
|
ops.put(key, "updatedAt", Instant.now().toString());
|
||||||
|
stringRedisTemplate.expire(key, Duration.ofHours(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildUploadStatusKey(String fileUuid) {
|
||||||
|
return "alist:upload:status:" + fileUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long parseLong(Object value) {
|
||||||
|
if (value == null) return 0L;
|
||||||
|
try {
|
||||||
|
return Long.parseLong(String.valueOf(value));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAuthCacheKey(UploadTokenDto dto) {
|
||||||
|
String normalizedName = normalize(dto.getOriginName());
|
||||||
|
String normalizedType = normalize(dto.getContentType());
|
||||||
|
String normalizedSize = (dto.getSizeBytes() == null) ? "" : String.valueOf(dto.getSizeBytes());
|
||||||
|
|
||||||
|
String raw = String.join("|",
|
||||||
|
normalize(dto.getFileUuid()),
|
||||||
|
dto.getUploadToken() == null ? "" : dto.getUploadToken(),
|
||||||
|
normalizedName,
|
||||||
|
normalizedType,
|
||||||
|
normalizedSize
|
||||||
|
);
|
||||||
|
|
||||||
|
return "alist:upload:auth:" + sha256(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String v) {
|
||||||
|
return (v == null) ? "" : v.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hashed = digest.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(hashed);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 algorithm not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long safeTtlSeconds() {
|
||||||
|
// 설정 실수 방어: 0/음수면 기본값 20초 사용
|
||||||
|
return (uploadAuthCacheTtlSeconds > 0) ? uploadAuthCacheTtlSeconds : 20L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isMetadataMismatch(FileUploadMetaDto fileUploadMetaDto, TusHookDto tusHookDto) {
|
||||||
|
if (tusHookDto.getSize() != null && fileUploadMetaDto.getSizeBytes() != null && !tusHookDto.getSize().equals(fileUploadMetaDto.getSizeBytes())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tusHookDto.getFilename() != null && !tusHookDto.getFilename().isBlank()
|
||||||
|
&& fileUploadMetaDto.getOriginName() != null && !fileUploadMetaDto.getOriginName().equals(tusHookDto.getFilename())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tusHookDto.getFiletype() != null && !tusHookDto.getFiletype().isBlank()
|
||||||
|
&& fileUploadMetaDto.getContentType() != null && !fileUploadMetaDto.getContentType().isBlank()
|
||||||
|
&& !fileUploadMetaDto.getContentType().equalsIgnoreCase(tusHookDto.getFiletype())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean updateUploadCancel(UploadCancelDto uploadCancelDto) {
|
||||||
|
if (uploadCancelDto.getFileUuid() == null || uploadCancelDto.getFileUuid().isBlank() || uploadCancelDto.getUserTokenIdx() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int allowed = fileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx(uploadCancelDto);
|
||||||
|
if (allowed <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadCancelDto progress = fileMapper.selectUploadProgressByFileUuid(uploadCancelDto);
|
||||||
|
long uploaded = (progress != null && progress.getUploadedBytes() != null) ? progress.getUploadedBytes() : 0L;
|
||||||
|
long total = (progress != null && progress.getSizeBytes() != null) ? progress.getSizeBytes() : 0L;
|
||||||
|
|
||||||
|
int updated = fileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx(uploadCancelDto);
|
||||||
|
if (updated <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileMapper.updateFileMasterAggregateByFileUuid(uploadCancelDto.getFileUuid());
|
||||||
|
saveCanceledStatusRedis(uploadCancelDto.getFileUuid(), uploaded, total); // 필요하면 기존 bytes 조회해서 넣어도 됨
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FileDownloadListDto selectFileDownloadListByFileMasterIdx(FileDownloadDto fileDownloadDto) {
|
||||||
|
FileDownloadListDto fileDownloadList = new FileDownloadListDto();
|
||||||
|
|
||||||
|
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
fileDownloadList.setResultCode(401);
|
||||||
|
return fileDownloadList;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileDownloadDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
|
fileDownloadList.setFileMasterIdx(fileDownloadDto.getFileMasterIdx());
|
||||||
|
|
||||||
|
List<FileDownloadItemDto> itemList = fileMapper.selectFileDownloadListByFileMasterIdx(fileDownloadDto);
|
||||||
|
|
||||||
|
for (FileDownloadItemDto item : itemList) {
|
||||||
|
item.setViewUrl("/files/view/" + item.getFileUuid());
|
||||||
|
item.setDownloadUrl("/files/download/" + item.getFileUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
fileDownloadList.setItemList(itemList);
|
||||||
|
fileDownloadList.setResultCode(200);
|
||||||
|
|
||||||
|
return fileDownloadList;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileDownloadDto selectFileViewOrDownload(FileDownloadDto fileDownloadDto) {
|
||||||
|
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileDownloadDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
|
||||||
|
FileDownloadDto fileDetailInfo = fileMapper.selectFileDetailByFileUuid(fileDownloadDto);
|
||||||
|
|
||||||
|
if (fileDetailInfo == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileDetailInfo.setUserTokenIdx(fileDownloadDto.getUserTokenIdx());
|
||||||
|
fileDetailInfo.setEventType(fileDownloadDto.getEventType());
|
||||||
|
fileDetailInfo.setClientIp(fileDownloadDto.getClientIp());
|
||||||
|
fileDetailInfo.setUserAgent(fileDownloadDto.getUserAgent());
|
||||||
|
fileDetailInfo.setReferer(fileDownloadDto.getReferer());
|
||||||
|
|
||||||
|
fileMapper.insertFileDownloadEventLog(fileDetailInfo);
|
||||||
|
return fileDetailInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveStoredFilePath(String savePath) {
|
||||||
|
if (savePath == null || savePath.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("savePath is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path root = Paths.get(uploadFinalRoot).normalize().toAbsolutePath();
|
||||||
|
String relativePath = savePath.startsWith("/") ? savePath.substring(1) : savePath;
|
||||||
|
|
||||||
|
Path resolved = root.resolve(relativePath).normalize();
|
||||||
|
|
||||||
|
if (!resolved.startsWith(root)) {
|
||||||
|
throw new IllegalArgumentException("invalid savePath");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean updateFileDelete(FileDeleteDto fileDeleteDto) {
|
||||||
|
if (fileDeleteDto == null || fileDeleteDto.getFileUuid() == null || fileDeleteDto.getFileUuid().isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx();
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileDeleteDto.setUserTokenIdx(userTokenIdx);
|
||||||
|
fileDeleteDto.setFileUuid(fileDeleteDto.getFileUuid().trim());
|
||||||
|
|
||||||
|
int allowed = fileMapper.selectFileDeleteTargetCount(fileDeleteDto);
|
||||||
|
if (allowed <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int detailUpdated = fileMapper.updateFileDetailDeleteByFileUuid(fileDeleteDto);
|
||||||
|
if (detailUpdated <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Integer selectUserIdxByUserTokenIdx(Integer userTokenIdx) {
|
||||||
|
if (userTokenIdx == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return fileMapper.selectUserIdxByUserTokenIdx(userTokenIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 @@
|
|||||||
|
package com.alist.api.modules.migration.alist.user.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class AlistUserDto {
|
||||||
|
private String id;
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.alist.api.modules.migration.alist.user.mapper;
|
||||||
|
|
||||||
|
import com.alist.api.modules.migration.alist.user.dto.AlistUserDto;
|
||||||
|
import com.alist.api.modules.migration.alist.user.vo.AlistUserVo;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface AlistUserMapper {
|
||||||
|
List<AlistUserVo> selectAlistUserList(AlistUserDto alistUserDto);
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.alist.api.modules.migration.alist.user.service;
|
||||||
|
|
||||||
|
import com.alist.api.modules.migration.alist.user.dto.AlistUserDto;
|
||||||
|
import com.alist.api.modules.migration.alist.user.mapper.AlistUserMapper;
|
||||||
|
import com.alist.api.modules.migration.alist.user.vo.AlistUserVo;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AlistUserService {
|
||||||
|
private final AlistUserMapper alistUserMapper;
|
||||||
|
|
||||||
|
public AlistUserService(AlistUserMapper alistUserMapper) {
|
||||||
|
this.alistUserMapper = alistUserMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AlistUserVo> selectAlistUserList(AlistUserDto alistUserDto) {
|
||||||
|
return alistUserMapper.selectAlistUserList(alistUserDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.alist.api.modules.migration.alist.user.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class AlistUserVo {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String hp;
|
||||||
|
private String email;
|
||||||
|
private String type;
|
||||||
|
private String joinDate;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.alist.api.modules.migration.eltown.user.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class EltownUserDto {
|
||||||
|
private String id;
|
||||||
|
private String password;
|
||||||
|
private String md5Password;
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.alist.api.modules.migration.eltown.user.mapper;
|
||||||
|
|
||||||
|
import com.alist.api.modules.migration.eltown.user.dto.EltownUserDto;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.vo.EltownUserVo;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface EltownUserMapper {
|
||||||
|
List<EltownUserVo> selectEltownUserList(EltownUserDto eltownUserDto);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.alist.api.modules.migration.eltown.user.service;
|
||||||
|
|
||||||
|
import com.alist.api.common.utils.HashUtils;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.dto.EltownUserDto;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.mapper.EltownUserMapper;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.vo.EltownUserVo;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class EltownUserService {
|
||||||
|
private final EltownUserMapper eltownUserMapper;
|
||||||
|
|
||||||
|
public EltownUserService(EltownUserMapper eltownUserMapper) {
|
||||||
|
this.eltownUserMapper = eltownUserMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EltownUserVo> selectEltownUserList(EltownUserDto eltownUserDto) {
|
||||||
|
eltownUserDto.setMd5Password(HashUtils.md5(eltownUserDto.getPassword()));
|
||||||
|
return eltownUserMapper.selectEltownUserList(eltownUserDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.alist.api.modules.migration.eltown.user.vo;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class EltownUserVo {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String hp;
|
||||||
|
private String email;
|
||||||
|
private String type;
|
||||||
|
private String joinDate;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.alist.api.modules.user;
|
||||||
|
|
||||||
|
import com.alist.api.common.response.ApiResponse;
|
||||||
|
import com.alist.api.common.response.ApiResponseCode;
|
||||||
|
import com.alist.api.modules.user.dto.UserDto;
|
||||||
|
import com.alist.api.modules.user.form.MigrationUserForm;
|
||||||
|
import com.alist.api.modules.user.form.UserSignupForm;
|
||||||
|
import com.alist.api.modules.user.service.UserService;
|
||||||
|
import com.alist.api.modules.user.vo.MigrationUserVo;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Tag(name = "05. 사용자 관리", description = "사용자 회원가입 및 관리 API")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/user")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
public final UserService userService;
|
||||||
|
|
||||||
|
public UserController(UserService userService) {
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "사용자 회원가입",
|
||||||
|
description = "새로운 사용자를 등록합니다. 아이디 중복 시 오류를 반환합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/signup")
|
||||||
|
public ResponseEntity<ApiResponse<UserDto>> signup(@Valid @RequestBody UserSignupForm userSignupForm) {
|
||||||
|
|
||||||
|
UserDto userDto = userService.insertUser(userSignupForm.toUserDto());
|
||||||
|
|
||||||
|
if (userDto.getResultCode() == 2004) {
|
||||||
|
return ApiResponse.entity(userDto, ApiResponseCode.CODE_2004, "아이디");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity(userDto, ApiResponseCode.CODE_2002, "아이디");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "이전 회원 정보 조회",
|
||||||
|
description = "이전 사이트의 회원 정보를 조회 후 list로 리턴합니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/migrationUserList")
|
||||||
|
public ResponseEntity<ApiResponse<MigrationUserVo>> migrationUserList(@Valid @RequestBody MigrationUserForm migrationUserForm) {
|
||||||
|
|
||||||
|
MigrationUserVo result = userService.selectMigrationUserList(migrationUserForm.toUserDto());
|
||||||
|
|
||||||
|
boolean empty = (result.getAlistUserList() == null || result.getAlistUserList().isEmpty()) && (result.getEltownUserList() == null || result.getEltownUserList().isEmpty());
|
||||||
|
|
||||||
|
if (empty) {
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2003);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "이전 회원");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.alist.api.modules.user.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
@Getter
|
||||||
|
public class UserDto {
|
||||||
|
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.user.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
@Getter
|
||||||
|
public class UserTokenDto {
|
||||||
|
private Integer userTokenIdx;
|
||||||
|
private Integer userIdx;
|
||||||
|
private String userApiKey;
|
||||||
|
private String userRole;
|
||||||
|
private int resultCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.alist.api.modules.user.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.user.dto.UserDto;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
@Getter
|
||||||
|
@Schema(description = "이전회원 정보 조회")
|
||||||
|
public class MigrationUserForm {
|
||||||
|
@Schema(
|
||||||
|
description = "사용자 아이디 (공백 불가)",
|
||||||
|
example = "test"
|
||||||
|
)
|
||||||
|
@NotBlank(message = "아이디를 입력해주세요.")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@NotBlank(message = "비밀번호를 입력해주세요.")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
public UserDto toUserDto() {
|
||||||
|
UserDto userDto = new UserDto();
|
||||||
|
userDto.setId(id.trim());
|
||||||
|
userDto.setPassword(password);
|
||||||
|
return userDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.alist.api.modules.user.form;
|
||||||
|
|
||||||
|
import com.alist.api.modules.user.dto.UserDto;
|
||||||
|
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 UserSignupForm {
|
||||||
|
@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 UserDto toUserDto() {
|
||||||
|
UserDto userDto = new UserDto();
|
||||||
|
userDto.setId(id.trim());
|
||||||
|
userDto.setPassword(password);
|
||||||
|
return userDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.alist.api.modules.user.mapper;
|
||||||
|
|
||||||
|
import com.alist.api.modules.user.dto.UserDto;
|
||||||
|
import com.alist.api.modules.user.dto.UserTokenDto;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UserMapper {
|
||||||
|
int selectDuplicateUserCount(UserDto userDto);
|
||||||
|
|
||||||
|
int insertUserSignup(UserDto userDto);
|
||||||
|
|
||||||
|
int insertUserTokenSignup(UserTokenDto userTokenDto);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.alist.api.modules.user.service;
|
||||||
|
|
||||||
|
import com.alist.api.common.utils.ApiKeyGenerator;
|
||||||
|
import com.alist.api.modules.migration.alist.user.dto.AlistUserDto;
|
||||||
|
import com.alist.api.modules.migration.alist.user.service.AlistUserService;
|
||||||
|
import com.alist.api.modules.migration.alist.user.vo.AlistUserVo;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.dto.EltownUserDto;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.service.EltownUserService;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.vo.EltownUserVo;
|
||||||
|
import com.alist.api.modules.user.dto.UserDto;
|
||||||
|
import com.alist.api.modules.user.dto.UserTokenDto;
|
||||||
|
import com.alist.api.modules.user.mapper.UserMapper;
|
||||||
|
import com.alist.api.modules.user.vo.MigrationUserVo;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class UserService {
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
private final AlistUserService alistUserService;
|
||||||
|
private final EltownUserService eltownUserService;
|
||||||
|
|
||||||
|
public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder, AlistUserService alistUserService, EltownUserService eltownUserService) {
|
||||||
|
this.userMapper = userMapper;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.alistUserService = alistUserService;
|
||||||
|
this.eltownUserService = eltownUserService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UserDto insertUser(UserDto userDto) {
|
||||||
|
// 중복체크
|
||||||
|
if (userMapper.selectDuplicateUserCount(userDto) > 0) {
|
||||||
|
userDto.setResultCode(2004);
|
||||||
|
return userDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 생성
|
||||||
|
userDto.setNewPassword(passwordEncoder.encode(userDto.getPassword()));
|
||||||
|
|
||||||
|
// 회원정보 입력
|
||||||
|
int result = userMapper.insertUserSignup(userDto);
|
||||||
|
|
||||||
|
if (result > 0) {
|
||||||
|
// user_api_key 생성
|
||||||
|
UserTokenDto userTokenDto = new UserTokenDto();
|
||||||
|
userTokenDto.setUserIdx(userDto.getUserIdx());
|
||||||
|
|
||||||
|
apiKeyWithRetry(userTokenDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int apiKeyWithRetry(UserTokenDto userTokenDto) {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
userTokenDto.setUserApiKey(ApiKeyGenerator.userApiKeyProc());
|
||||||
|
try {
|
||||||
|
return userMapper.insertUserTokenSignup(userTokenDto);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
// 충돌이면 다시 생성
|
||||||
|
log.warn("Duplicate API key. retrying... userId={}", userTokenDto.getUserIdx());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("API key generation failed after retries.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MigrationUserVo selectMigrationUserList(UserDto userDto) {
|
||||||
|
AlistUserDto alistUserDto = new AlistUserDto();
|
||||||
|
alistUserDto.setId(userDto.getId());
|
||||||
|
alistUserDto.setPassword(userDto.getPassword());
|
||||||
|
|
||||||
|
// alist 조회
|
||||||
|
List<AlistUserVo> alistUserList = alistUserService.selectAlistUserList(alistUserDto);
|
||||||
|
|
||||||
|
EltownUserDto eltownUserDto = new EltownUserDto();
|
||||||
|
eltownUserDto.setId(userDto.getId());
|
||||||
|
eltownUserDto.setPassword(userDto.getPassword());
|
||||||
|
|
||||||
|
// eltown 조회
|
||||||
|
List<EltownUserVo> eltownUserList = eltownUserService.selectEltownUserList(eltownUserDto);
|
||||||
|
|
||||||
|
MigrationUserVo result = new MigrationUserVo();
|
||||||
|
|
||||||
|
result.setAlistUserList(alistUserList);
|
||||||
|
result.setEltownUserList(eltownUserList);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.alist.api.modules.user.vo;
|
||||||
|
|
||||||
|
import com.alist.api.modules.migration.alist.user.vo.AlistUserVo;
|
||||||
|
import com.alist.api.modules.migration.eltown.user.vo.EltownUserVo;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class MigrationUserVo {
|
||||||
|
List<AlistUserVo> alistUserList;
|
||||||
|
List<EltownUserVo> eltownUserList;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 10 # 줄이기
|
||||||
|
minimum-idle: 1 # 최소로
|
||||||
|
connection-timeout: 10000
|
||||||
|
idle-timeout: 30000
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: 121.160.234.222
|
||||||
|
port: 3001
|
||||||
|
password: 1qaz2wsx!@
|
||||||
|
|
||||||
|
migration:
|
||||||
|
datasource:
|
||||||
|
alist:
|
||||||
|
jdbc-url: jdbc:log4jdbc:sqlserver://bigfuntnp.co.kr:51433;databaseName=alist_dev;encrypt=false;trustServerCertificate=true
|
||||||
|
username: bigfuntnp
|
||||||
|
password: password12!@
|
||||||
|
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||||
|
maximum-pool-size: 5
|
||||||
|
minimum-idle: 1
|
||||||
|
connection-timeout: 10000
|
||||||
|
eltown:
|
||||||
|
jdbc-url: jdbc:log4jdbc:sqlserver://bigfuntnp.co.kr:51433;databaseName=eltown;encrypt=false;trustServerCertificate=true
|
||||||
|
username: bigfuntnp
|
||||||
|
password: password12!@
|
||||||
|
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||||
|
maximum-pool-size: 5
|
||||||
|
minimum-idle: 1
|
||||||
|
connection-timeout: 10000
|
||||||
|
|
||||||
|
logging:
|
||||||
|
config: classpath:logback-local.xml
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: "F9k3s!29dkF#1lP0X9QZx8eW!2m@0AbD"
|
||||||
|
access-token-validity-seconds: 3600
|
||||||
|
refresh-token-validity-seconds: 2592000
|
||||||
|
|
||||||
|
cookie:
|
||||||
|
secure: false # (HTTP) -> false
|
||||||
|
domain: api-alist.pjt.kr # (HTTP) -> 비워두세요
|
||||||
|
name: ALIST_SSO
|
||||||
|
same-site: Lax # (HTTP) -> Lax
|
||||||
|
|
||||||
|
swagger:
|
||||||
|
login:
|
||||||
|
id: alist
|
||||||
|
password: "1qaz2wsx!@"
|
||||||
|
|
||||||
|
file:
|
||||||
|
upload:
|
||||||
|
tus-endpoint: https://file-alist.pjt.kr/tus/files/
|
||||||
|
public-base-url: https://file-alist.pjt.kr
|
||||||
|
tmp-root: /srv/project/alist/uploads/tmp
|
||||||
|
final-root: /srv/project/alist/uploads
|
||||||
|
interrupt-seconds: 30
|
||||||
|
auth-cache:
|
||||||
|
ttl-seconds: 20
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: true
|
||||||
|
swagger-ui:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
sso:
|
||||||
|
session:
|
||||||
|
ttl-seconds: 7200
|
||||||
|
code:
|
||||||
|
ttl-seconds: 300
|
||||||
|
cookie:
|
||||||
|
name: ALIST_SSO
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 10 # 줄이기
|
||||||
|
minimum-idle: 1 # 최소로
|
||||||
|
connection-timeout: 10000
|
||||||
|
idle-timeout: 30000
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST}
|
||||||
|
port: ${REDIS_PORT}
|
||||||
|
password: ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
migration:
|
||||||
|
datasource:
|
||||||
|
alist:
|
||||||
|
jdbc-url: jdbc:log4jdbc:sqlserver://${ALIST_DB_HOST}:${ALIST_DB_PORT};databaseName=${ALIST_DB_NAME};encrypt=false;trustServerCertificate=true
|
||||||
|
username: ${ALIST_DB_USERNAME}
|
||||||
|
password: ${ALIST_DB_PASSWORD}
|
||||||
|
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||||
|
maximum-pool-size: 5
|
||||||
|
minimum-idle: 1
|
||||||
|
connection-timeout: 10000
|
||||||
|
eltown:
|
||||||
|
jdbc-url: jdbc:log4jdbc:sqlserver://${ELTOWN_DB_HOST}:${ELTOWN_DB_PORT};databaseName=${ELTOWN_DB_NAME};encrypt=false;trustServerCertificate=true
|
||||||
|
username: ${ELTOWN_DB_USERNAME}
|
||||||
|
password: ${ELTOWN_DB_PASSWORD}
|
||||||
|
driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
|
||||||
|
maximum-pool-size: 5
|
||||||
|
minimum-idle: 1
|
||||||
|
connection-timeout: 10000
|
||||||
|
|
||||||
|
logging:
|
||||||
|
config: classpath:logback-pjt.xml
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET}
|
||||||
|
access-token-validity-seconds: 3600
|
||||||
|
refresh-token-validity-seconds: 2592000
|
||||||
|
|
||||||
|
cookie:
|
||||||
|
secure: true # 프로젝트 환경 (HTTPS)
|
||||||
|
domain: api-alist.pjt.kr
|
||||||
|
name: ALIST_SSO
|
||||||
|
same-site: None
|
||||||
|
|
||||||
|
swagger:
|
||||||
|
login:
|
||||||
|
id: ${SWAGGER_ID}
|
||||||
|
password: ${SWAGGER_PASSWORD}
|
||||||
|
|
||||||
|
file:
|
||||||
|
upload:
|
||||||
|
tus-endpoint: https://file-alist.pjt.kr/tus/files/
|
||||||
|
public-base-url: https://file-alist.pjt.kr
|
||||||
|
tmp-root: /srv/project/alist/uploads/tmp
|
||||||
|
final-root: /srv/project/alist/uploads
|
||||||
|
interrupt-seconds: 30
|
||||||
|
auth-cache:
|
||||||
|
ttl-seconds: 20
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: true
|
||||||
|
swagger-ui:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
sso:
|
||||||
|
session:
|
||||||
|
ttl-seconds: 7200
|
||||||
|
code:
|
||||||
|
ttl-seconds: 300
|
||||||
|
cookie:
|
||||||
|
name: ALIST_SSO
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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
|
||||||
|
tags-sorter: alpha # 태그 알파벳 순 정렬
|
||||||
|
operations-sorter: alpha # API 알파벳 순 정렬
|
||||||
|
api-docs:
|
||||||
|
path: /v3/api-docs
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: always
|
||||||
|
health:
|
||||||
|
db:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
q p ,,
|
||||||
|
db \ / `7MMF' db mm
|
||||||
|
;MM:o=--*--=o MM MM
|
||||||
|
,V^MM. / \ MM `7MM ,pP"Ybd mmMMmm
|
||||||
|
,M `MM d b MM MM 8I `" MM
|
||||||
|
AbmmmqMA MM , MM `YMMMa. MM
|
||||||
|
A' VML MM ,M MM L. I8 MM
|
||||||
|
.AMA. .AMMA. .JMMmmmmMMM .JMML.M9mmmP' `Mbmo
|
||||||
|
|
||||||
|
|
||||||
|
:: ALIST API ::
|
||||||
|
Application : ${spring.application.name}
|
||||||
|
Profile : ${spring.profiles.active}
|
||||||
|
Spring Boot : ${spring-boot.version}
|
||||||
|
Java : ${java.version}
|
||||||
|
Port : ${server.port}
|
||||||
|
PID : ${PID}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user