[SSO] 추가
This commit is contained in:
@@ -38,3 +38,5 @@ out/
|
||||
|
||||
### Claude Code ###
|
||||
.claude/
|
||||
|
||||
AGENTS.md
|
||||
@@ -1,162 +0,0 @@
|
||||
# CLAUDE.md - alist API 프로젝트
|
||||
|
||||
## Claude 작업 규칙 (중요!)
|
||||
|
||||
### 코드 수정 시 반드시 따를 것
|
||||
1. **절대로 바로 파일을 수정하지 말 것**
|
||||
2. **먼저 수정 방향과 계획을 설명**
|
||||
3. **긴 소스 코드를 보여주기 전에 먼저 물어보기**
|
||||
- "코드 보여드릴까요?" 또는 "직접 수정하시겠어요?"
|
||||
- 사용자가 원하면 코드 보여주기 (사용자가 직접 수정할 수도 있도록)
|
||||
4. **파일 작성/수정은 사용자가 요청할 때만**
|
||||
|
||||
### 작업 순서 예시
|
||||
```
|
||||
1. "CORS 설정을 yml로 분리하겠습니다."
|
||||
2. "수정 내용:"
|
||||
- CorsProperties.java 생성 필요
|
||||
- CorsConfig.java 수정 필요
|
||||
- application-local.yaml 수정 필요
|
||||
3. "코드 보여드릴까요? 아니면 직접 수정하시겠어요?"
|
||||
4. (사용자가 "보여줘"라고 하면) 코드 보여주기
|
||||
5. (사용자가 "작성해줘"라고 하면) 파일 수정 진행
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
- **짧은 설명(수정 위치, 수정 방향)은 바로 보여줘도 됨**
|
||||
- **전체 메서드/클래스 같은 긴 코드는 반드시 물어본 후 보여주기**
|
||||
- **화면이 너무 길어지는 것 방지**
|
||||
|
||||
## 프로젝트 개요
|
||||
- **프로젝트명**: alist API
|
||||
- **그룹**: com.alist
|
||||
- **포트**: 8106
|
||||
- **빌드 결과물**: `api.jar`
|
||||
|
||||
## 기술 스택
|
||||
- **Java**: 21
|
||||
- **Framework**: Spring Boot 3.5.10
|
||||
- **빌드 도구**: Gradle
|
||||
- **DB**: MariaDB
|
||||
- **ORM**: MyBatis (mapper XML: `classpath:mapper/**/*.xml`)
|
||||
- **인증**: JWT (jjwt 0.11.5) + Spring Security
|
||||
- **API 문서**: Swagger (springdoc-openapi 2.6.0)
|
||||
- **기타**: Lombok, Validation, Actuator, log4jdbc
|
||||
|
||||
## 패키지 구조
|
||||
```
|
||||
com.alist.api
|
||||
├── common
|
||||
│ ├── response/ # ApiResponse, ApiResponseCode
|
||||
│ └── utils/ # 공통 유틸리티
|
||||
├── config
|
||||
│ ├── jwt/ # JWT 필터, 핸들러, Provider
|
||||
│ ├── exception/ # GlobalExceptionHandler
|
||||
│ ├── properties/ # JwtProperties 등
|
||||
│ ├── SecurityConfig.java
|
||||
│ └── OpenApiConfig.java
|
||||
└── modules
|
||||
└── {도메인}/ # Controller, Service, Mapper, DTO
|
||||
```
|
||||
|
||||
## 프로파일
|
||||
| 프로파일 | 설명 |
|
||||
|---------|------|
|
||||
| `local` | 로컬 개발 환경 |
|
||||
| `pjt` | 프로젝트(개발) 환경 |
|
||||
|
||||
## 보안 구조
|
||||
- **Swagger**: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory)
|
||||
- **API**: JWT Bearer 토큰 인증 (Stateless)
|
||||
- **공개 경로**: `/`, `/actuator/health`, `/auth/**`, `/api/user/signup`
|
||||
|
||||
## 응답 코드 규칙
|
||||
|
||||
`ApiResponseCode` enum으로 관리. 주요 코드:
|
||||
|
||||
| 코드 | 메시지 | HTTP Status | 용도 |
|
||||
|------|--------|-------------|------|
|
||||
| `CODE_200` | 성공 | 200 OK | 일반 성공 |
|
||||
| `CODE_400` | 잘못된 요청 | 400 Bad Request | 일반 클라이언트 오류 |
|
||||
| `CODE_401` | 인증 필요 합니다. | 401 Unauthorized | 인증 없음 |
|
||||
| `CODE_403` | 접근 권한 필요 합니다. | 403 Forbidden | 권한 없음 |
|
||||
| `CODE_404` | 페이지를 찾을 수 없습니다. | 404 Not Found | 리소스 없음 |
|
||||
| `CODE_405` | 잘못된 요청입니다. 요청 방식을 확인해 주세요. | 405 Method Not Allowed | 메서드 불일치 |
|
||||
| `CODE_500` | 요청을 처리하는 중 오류가 발생했습니다. | 500 Internal Server Error | 서버 오류 |
|
||||
| `CODE_2001` | {0} 정보 조회에 성공하였습니다. | 200 OK | 단건 조회 성공 |
|
||||
| `CODE_2002` | {0} 등록 되었습니다. | 201 Created | 등록 성공 |
|
||||
| `CODE_2003` | 조회된 정보가 없습니다. | 200 OK | 조회 결과 없음 |
|
||||
| `CODE_2004` | 중복된 {0} 정보 입니다. | 409 Conflict | 중복 데이터 |
|
||||
| `CODE_4001` | 입력값을 확인해주세요. | 400 Bad Request | `@Valid` / 바인딩 / 타입오류 / JSON 파싱 실패 |
|
||||
| `CODE_4003` | 필수 요청 파라미터가 누락되었습니다. | 400 Bad Request | 필수 파라미터 누락 |
|
||||
|
||||
- `{0}` 자리에 대상명 삽입 (예: `CODE_2001` → "회원 정보 조회에 성공하였습니다.")
|
||||
|
||||
## 빌드 및 실행
|
||||
```bash
|
||||
# 빌드
|
||||
./gradlew bootJar
|
||||
|
||||
# 로컬 실행
|
||||
./gradlew bootRun --args='--spring.profiles.active=local'
|
||||
|
||||
# JAR 실행
|
||||
java -jar build/libs/api.jar --spring.profiles.active=local
|
||||
```
|
||||
|
||||
## Swagger 접속
|
||||
- URL: `http://localhost:8106/swagger-ui.html`
|
||||
- 인증: `swagger.login.id` / `swagger.login.password` (환경별 yaml에 설정)
|
||||
|
||||
## 코드 작성 규칙
|
||||
- 응답은 `ApiResponse<T>` 래퍼 사용
|
||||
- 응답 코드는 `ApiResponseCode` enum 사용
|
||||
- MyBatis Mapper XML은 `src/main/resources/mapper/` 하위에 작성
|
||||
- 카멜케이스 자동 변환 활성화 (`map-underscore-to-camel-case: true`)
|
||||
- 새 모듈 추가 시: `modules/{moduleName}/` 하위에 Controller, Service, Mapper, dto/, vo/ 구조로 생성
|
||||
- Mapper XML은 `resources/mapper/{moduleName}/` 에 위치
|
||||
|
||||
## MyBatis 규칙
|
||||
- Mapper XML 위치: `src/main/resources/mapper/**/*.xml`
|
||||
- `map-underscore-to-camel-case: true` 설정 → DB 컬럼 `user_idx` → Java 필드 `userIdx` 자동 매핑
|
||||
- Mapper 인터페이스와 XML의 namespace, id 반드시 일치시킬 것
|
||||
- VO: DB 조회 결과 매핑용 / DTO: 서비스 레이어 간 데이터 전달용 / Form: 컨트롤러 입력 검증용
|
||||
|
||||
## CI/CD
|
||||
- **Jenkins**: `Jenkinsfile.pjt`
|
||||
- **Docker**: `Dockerfile`
|
||||
- **배포 스크립트**: `deploy/` 디렉토리
|
||||
|
||||
## 개발 서버 (pjt)
|
||||
|
||||
### 도메인
|
||||
- **API**: `api-alist.pjt.kr`
|
||||
- **파일(업로드)**: `file-alist.pjt.kr`
|
||||
- **Swagger**: `https://api-alist.pjt.kr/swagger-ui/index.html`
|
||||
|
||||
### Docker
|
||||
- **레지스트리**: `registry.pjt.kr`
|
||||
- **이미지**: `registry.pjt.kr/alist/api`
|
||||
- **컨테이너명**: `alist-api`
|
||||
- **포트**: `127.0.0.1:8106->8106/tcp`
|
||||
|
||||
### 서버 디렉토리 (`/srv/project/alist/`)
|
||||
```
|
||||
/srv/project/alist/
|
||||
├── compose/ # docker-compose 파일
|
||||
├── data/ # 데이터
|
||||
├── env/ # 환경변수 파일
|
||||
├── logs/ # 로그
|
||||
├── scripts/ # 배포/운영 스크립트
|
||||
└── uploads/ # 업로드 파일 (file-alist.pjt.kr 루트)
|
||||
```
|
||||
|
||||
### Nginx
|
||||
- `file-alist.pjt.kr` → `/srv/project/alist/uploads` (정적 파일 서빙)
|
||||
- HTTP(80) → HTTPS(301) 리다이렉트
|
||||
- SSL: Let's Encrypt
|
||||
- 직접 접근 차단 (`allow 127.0.0.1; deny all;`)
|
||||
|
||||
### 파일 업로드
|
||||
- 업로드 저장 경로: `/srv/project/alist/uploads/`
|
||||
- 업로드 파일 접근 URL: `https://file-alist.pjt.kr/{파일경로}`
|
||||
@@ -56,6 +56,10 @@ dependencies {
|
||||
|
||||
// health
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
|
||||
// Redis (SSO 세션 공유)
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
implementation 'org.springframework.session:spring-session-data-redis'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.alist.api.common.utils;
|
||||
|
||||
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) {
|
||||
ResponseCookie cookie = ResponseCookie.from(name, "")
|
||||
.domain(cookieDomain)
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(cookieSecure)
|
||||
.sameSite("Lax")
|
||||
.maxAge(0)
|
||||
.build();
|
||||
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
|
||||
// 쿠키 입력
|
||||
public static void addTokenCookie(HttpServletResponse response, String name, String value, String cookieDomain, boolean cookieSecure, long maxAgeSeconds) {
|
||||
ResponseCookie cookie = ResponseCookie.from(name, value)
|
||||
.domain(cookieDomain)
|
||||
.path("/")
|
||||
.httpOnly(true)
|
||||
.secure(cookieSecure)
|
||||
.sameSite("Lax")
|
||||
.maxAge(maxAgeSeconds)
|
||||
.build();
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.alist.api.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
|
||||
import org.springframework.session.web.http.CookieSerializer;
|
||||
import org.springframework.session.web.http.DefaultCookieSerializer;
|
||||
|
||||
@Configuration
|
||||
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 7200) // 2시간
|
||||
public class RedisSessionConfig {
|
||||
|
||||
@Value("${cookie.secure}")
|
||||
private boolean cookieSecure;
|
||||
|
||||
@Value("${cookie.domain}")
|
||||
private String cookieDomain;
|
||||
|
||||
@Value("${cookie.name}")
|
||||
private String cookieName;
|
||||
|
||||
@Bean
|
||||
public CookieSerializer cookieSerializer() {
|
||||
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
|
||||
|
||||
// SSO를 위한 쿠키 설정
|
||||
serializer.setCookieName(cookieName);
|
||||
serializer.setDomainName(cookieDomain); // 모든 서브도메인에서 공유
|
||||
serializer.setCookiePath("/");
|
||||
serializer.setUseSecureCookie(cookieSecure); // 환경별 설정 (local: false, pjt: true)
|
||||
serializer.setUseHttpOnlyCookie(true); // XSS 방지
|
||||
serializer.setSameSite("Lax"); // CSRF 방지
|
||||
|
||||
return serializer;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public class SecurityConfig {
|
||||
http
|
||||
.cors(Customizer.withDefaults())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// SSO를 위해 Spring Session 사용 (sessionManagement 설정 제거하여 기본값 사용)
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
|
||||
.accessDeniedHandler(new JwtAccessDeniedHandler())
|
||||
|
||||
@@ -24,9 +24,7 @@ public class DynamicCorsFilter extends OncePerRequestFilter {
|
||||
private final CorsAllowedOriginsCache corsCache;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String origin = request.getHeader("Origin");
|
||||
|
||||
|
||||
@@ -22,11 +22,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
String token = resolveToken(request);
|
||||
@@ -48,14 +44,28 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
// 1) Authorization: Bearer xxx 우선
|
||||
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (bearer == null) return null;
|
||||
|
||||
// "Bearer " 뒤 토큰만 추출
|
||||
if (bearer.startsWith("Bearer ")) {
|
||||
if (bearer != null && bearer.startsWith("Bearer ")) {
|
||||
String token = bearer.substring(7).trim();
|
||||
return token.isEmpty() ? null : token;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ public class JwtTokenProvider {
|
||||
/* 리프레시 토큰 생성 */
|
||||
public String createRefreshToken(long userTokenIdx) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds());
|
||||
Instant expiry = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(String.valueOf(userTokenIdx))
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.alist.api.modules.auth;
|
||||
|
||||
import com.alist.api.common.utils.SessionUtil;
|
||||
import com.alist.api.modules.auth.dto.TestLoginDto;
|
||||
import com.alist.api.modules.auth.dto.TokenDto;
|
||||
import com.alist.api.modules.auth.service.TestAuthService;
|
||||
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.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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 org.springframework.web.bind.annotation.*;
|
||||
|
||||
import com.alist.api.common.response.ApiResponse;
|
||||
import com.alist.api.common.response.ApiResponseCode;
|
||||
@@ -16,14 +20,35 @@ import com.alist.api.modules.auth.form.TokenForm;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "00. 엑세스 토큰 발급", description = "강제 엑세스 토큰 발급 (JSON 전달, 테스트용)")
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public AuthController(JwtTokenProvider jwtTokenProvider) {
|
||||
@Value("${cookie.secure}")
|
||||
private boolean cookieSecure;
|
||||
|
||||
@Value("${cookie.domain}")
|
||||
private String cookieDomain;
|
||||
|
||||
@Value("${cookie.name}")
|
||||
private String cookieName;
|
||||
|
||||
@Value("${jwt.access-token-validity-seconds}")
|
||||
private long accessTokenValiditySeconds;
|
||||
|
||||
@Value("${jwt.refresh-token-validity-seconds}")
|
||||
private long refreshTokenValiditySeconds;
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final TestAuthService testAuthService;
|
||||
|
||||
public AuthController(JwtTokenProvider jwtTokenProvider, TestAuthService testAuthService) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
this.testAuthService = testAuthService;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
@@ -37,4 +62,77 @@ public class AuthController {
|
||||
|
||||
return ApiResponse.entity(tokenDto, ApiResponseCode.CODE_2001, "엑세스 토큰");
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "리프레시 토큰으로 엑세스 토큰 재발급",
|
||||
description = "리프레시 토큰으로 엑세스 토큰 재발급"
|
||||
)
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> refresh(@CookieValue(name = "refreshToken", required = false) String refreshToken, HttpServletResponse response) {
|
||||
|
||||
TestLoginDto testLogin = testAuthService.refreshByToken(refreshToken);
|
||||
|
||||
if (testLogin == null) {
|
||||
return ApiResponse.entity(Map.of("refreshed", false), ApiResponseCode.CODE_401);
|
||||
}
|
||||
|
||||
SessionUtil.addTokenCookie(response, "accessToken", testLogin.getAccessToken(), cookieDomain, cookieSecure, accessTokenValiditySeconds);
|
||||
SessionUtil.addTokenCookie(response, "refreshToken", testLogin.getRefreshToken(), cookieDomain, cookieSecure, refreshTokenValiditySeconds);
|
||||
|
||||
return ApiResponse.entity(Map.of("refreshed", true), ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "SSO 로그인 상태 확인",
|
||||
description = "SSO 인증 받은 후 현재 세션이 있는지 확인"
|
||||
)
|
||||
@GetMapping("/loginChecked")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> loginChecked(HttpServletRequest request) {
|
||||
Map<String, Object> sessionInfo = new HashMap<>();
|
||||
HttpSession session = request.getSession(false); // 새 세션 생성 금지
|
||||
|
||||
if (session == null) {
|
||||
sessionInfo.put("loggedIn", false);
|
||||
return ApiResponse.entity(sessionInfo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
Integer userIdx = (Integer) session.getAttribute("userIdx");
|
||||
if (userIdx == null) {
|
||||
sessionInfo.put("loggedIn", false);
|
||||
return ApiResponse.entity(sessionInfo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
sessionInfo.put("loggedIn", true);
|
||||
sessionInfo.put("userIdx", userIdx);
|
||||
sessionInfo.put("userTokenIdx", session.getAttribute("userTokenIdx"));
|
||||
sessionInfo.put("userRole", session.getAttribute("userRole"));
|
||||
sessionInfo.put("userId", session.getAttribute("userId"));
|
||||
sessionInfo.put("sessionId", session.getId());
|
||||
|
||||
return ApiResponse.entity(sessionInfo, ApiResponseCode.CODE_200);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "로그아웃",
|
||||
description = "로그아웃시 redis 에 세션 삭제 합니다."
|
||||
)
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> logout(HttpServletRequest request, HttpServletResponse response, @CookieValue(name = "refreshToken", required = false) String refreshToken) {
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
|
||||
testAuthService.clearRefreshToken(refreshToken);
|
||||
|
||||
SessionUtil.expireCookie(response, cookieName, cookieDomain, cookieSecure);
|
||||
SessionUtil.expireCookie(response, "accessToken", cookieDomain, cookieSecure);
|
||||
SessionUtil.expireCookie(response, "refreshToken", cookieDomain, cookieSecure);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("message", "로그아웃되었습니다.");
|
||||
|
||||
return ApiResponse.entity(result, ApiResponseCode.CODE_200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,31 +2,37 @@ 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.TestLoginDto;
|
||||
import com.alist.api.modules.auth.form.TestApikeyLoginForm;
|
||||
import com.alist.api.modules.auth.form.TestLoginForm;
|
||||
import com.alist.api.modules.auth.service.TestAuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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 org.springframework.web.bind.annotation.*;
|
||||
|
||||
@Tag(name = "01. 테스트 인증", description = "테스트 사용자 로그인 및 API Key 인증 관련 API")
|
||||
@RestController
|
||||
@RequestMapping("/test")
|
||||
public class TestAuthController {
|
||||
private final TestAuthService testAuthService;
|
||||
|
||||
@Value("${cookie.secure}")
|
||||
private boolean cookieSecure;
|
||||
|
||||
@Value("${cookie.domain}")
|
||||
private String cookieDomain;
|
||||
|
||||
@Value("${jwt.access-token-validity-seconds}")
|
||||
private long accessTokenValiditySeconds;
|
||||
|
||||
@Value("${jwt.refresh-token-validity-seconds}")
|
||||
private long refreshTokenValiditySeconds;
|
||||
|
||||
public TestAuthController(TestAuthService testAuthService) {
|
||||
this.testAuthService = testAuthService;
|
||||
}
|
||||
@@ -36,30 +42,24 @@ public class TestAuthController {
|
||||
description = "아이디/비밀번호로 로그인하고 HttpOnly 쿠키로 토큰을 전달합니다. (웹 브라우저용)"
|
||||
)
|
||||
@PostMapping("/testLogin")
|
||||
public ResponseEntity<ApiResponse<TestLoginDto>> testUserLogin(
|
||||
@Valid @RequestBody TestLoginForm testLoginForm,
|
||||
HttpServletResponse response) {
|
||||
public ResponseEntity<ApiResponse<TestLoginDto>> testUserLogin(@Valid @RequestBody TestLoginForm testLoginForm, HttpServletResponse response, HttpSession session) {
|
||||
TestLoginDto testLoginDto = testAuthService.selectTestLogin(testLoginForm.testLoginDto());
|
||||
|
||||
if (testLoginDto.getResultCode() == 2003) {
|
||||
return ApiResponse.entity(testLoginDto, ApiResponseCode.CODE_2003);
|
||||
}
|
||||
|
||||
// SSO를 위한 세션에 사용자 정보 저장
|
||||
session.setAttribute("userIdx", testLoginDto.getUserIdx());
|
||||
session.setAttribute("userTokenIdx", testLoginDto.getUserTokenIdx());
|
||||
session.setAttribute("userId", testLoginDto.getId());
|
||||
session.setAttribute("userRole", "USER");
|
||||
|
||||
// accessToken 쿠키 설정
|
||||
Cookie accessCookie = new Cookie("accessToken", testLoginDto.getAccessToken());
|
||||
accessCookie.setHttpOnly(true); // JS 접근 차단 (XSS 방지)
|
||||
accessCookie.setSecure(cookieSecure); // HTTPS 전송 (환경별 설정)
|
||||
accessCookie.setPath("/");
|
||||
accessCookie.setMaxAge(3600); // 1시간
|
||||
response.addCookie(accessCookie);
|
||||
SessionUtil.addTokenCookie(response, "accessToken", testLoginDto.getAccessToken(), cookieDomain, cookieSecure, accessTokenValiditySeconds);
|
||||
|
||||
// refreshToken 쿠키 설정
|
||||
Cookie refreshCookie = new Cookie("refreshToken", testLoginDto.getRefreshToken());
|
||||
refreshCookie.setHttpOnly(true);
|
||||
refreshCookie.setSecure(cookieSecure); // HTTPS 전송 (환경별 설정)
|
||||
refreshCookie.setPath("/");
|
||||
refreshCookie.setMaxAge(7 * 24 * 3600); // 7일
|
||||
response.addCookie(refreshCookie);
|
||||
SessionUtil.addTokenCookie(response, "refreshToken", testLoginDto.getRefreshToken(), cookieDomain, cookieSecure, refreshTokenValiditySeconds);
|
||||
|
||||
// JSON 응답에서는 토큰 제거 (쿠키로만 전달)
|
||||
testLoginDto.setAccessToken(null);
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.alist.api.modules.auth.dto.TestLoginDto;
|
||||
import com.alist.api.modules.auth.vo.TestLoginTokenVo;
|
||||
import com.alist.api.modules.auth.vo.TestLoginVo;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface TestAuthMapper {
|
||||
@@ -14,4 +15,10 @@ public interface TestAuthMapper {
|
||||
TestLoginTokenVo selectTestUserTokenByUserApiKey(TestLoginDto testLoginDto);
|
||||
|
||||
void updateRefreshToken(TestLoginDto testLoginDto);
|
||||
|
||||
TestLoginTokenVo selectTestUserTokenByUserTokenIdx(@Param("userTokenIdx") int userTokenIdx);
|
||||
|
||||
void clearRefreshTokenByUserTokenIdx(@Param("userTokenIdx") int userTokenIdx);
|
||||
|
||||
void updateTestLoginByLastAt(TestLoginDto testLoginDto);
|
||||
}
|
||||
|
||||
@@ -28,60 +28,106 @@ public class TestAuthService {
|
||||
}
|
||||
|
||||
// id login
|
||||
public TestLoginDto selectTestLogin(TestLoginDto loginDto) {
|
||||
TestLoginVo testLoginVo = testAuthMapper.selectTestLogin(loginDto);
|
||||
public TestLoginDto selectTestLogin(TestLoginDto testLoginDto) {
|
||||
TestLoginVo testLoginVo = testAuthMapper.selectTestLogin(testLoginDto);
|
||||
|
||||
if (testLoginVo != null) {
|
||||
if (passwordEncoder.matches(loginDto.getPassword(), testLoginVo.getPassword())) {
|
||||
if (passwordEncoder.matches(testLoginDto.getPassword(), testLoginVo.getPassword())) {
|
||||
|
||||
loginDto.setUserIdx(testLoginVo.getUserIdx());
|
||||
testLoginDto.setUserIdx(testLoginVo.getUserIdx());
|
||||
|
||||
TestLoginTokenVo testLoginTokenVo = testAuthMapper.selectTestUserTokenByUserId(loginDto);
|
||||
TestLoginTokenVo testLoginTokenVo = testAuthMapper.selectTestUserTokenByUserId(testLoginDto);
|
||||
|
||||
if (testLoginTokenVo != null) {
|
||||
loginDto.setAccessToken(jwtTokenProvider.createAccessToken(testLoginTokenVo.getUserTokenIdx(), testLoginTokenVo.getUserRole()));
|
||||
loginDto.setRefreshToken(jwtTokenProvider.createRefreshToken(testLoginTokenVo.getUserTokenIdx()));
|
||||
testLoginDto.setAccessToken(jwtTokenProvider.createAccessToken(testLoginTokenVo.getUserTokenIdx(), testLoginTokenVo.getUserRole()));
|
||||
testLoginDto.setRefreshToken(jwtTokenProvider.createRefreshToken(testLoginTokenVo.getUserTokenIdx()));
|
||||
|
||||
loginDto.setUserTokenIdx(testLoginTokenVo.getUserTokenIdx());
|
||||
testLoginDto.setUserTokenIdx(testLoginTokenVo.getUserTokenIdx());
|
||||
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plusSeconds(jwtProperties.getRefreshTokenValiditySeconds());
|
||||
|
||||
loginDto.setExpiresAt(expiresAt);
|
||||
testLoginDto.setExpiresAt(expiresAt);
|
||||
|
||||
testAuthMapper.updateRefreshToken(loginDto);
|
||||
testAuthMapper.updateRefreshToken(testLoginDto);
|
||||
|
||||
loginDto.setResultCode(2001);
|
||||
// 마지막 로그인 시간 추가
|
||||
testAuthMapper.updateTestLoginByLastAt(testLoginDto);
|
||||
|
||||
testLoginDto.setResultCode(2001);
|
||||
} else {
|
||||
loginDto.setResultCode(2003);
|
||||
testLoginDto.setResultCode(2003);
|
||||
}
|
||||
} else {
|
||||
loginDto.setResultCode(2003);
|
||||
testLoginDto.setResultCode(2003);
|
||||
}
|
||||
} else {
|
||||
loginDto.setResultCode(2003);
|
||||
testLoginDto.setResultCode(2003);
|
||||
}
|
||||
|
||||
return loginDto;
|
||||
return testLoginDto;
|
||||
}
|
||||
|
||||
// api key login
|
||||
public TestLoginDto selectTestUserApiKeyLogin(TestLoginDto loginDto) {
|
||||
if (loginDto.getUserApiKey() != null) {
|
||||
TestLoginTokenVo testLoginTokenVo = testAuthMapper.selectTestUserTokenByUserApiKey(loginDto);
|
||||
public TestLoginDto selectTestUserApiKeyLogin(TestLoginDto testLoginDto) {
|
||||
if (testLoginDto.getUserApiKey() != null) {
|
||||
TestLoginTokenVo testLoginTokenVo = testAuthMapper.selectTestUserTokenByUserApiKey(testLoginDto);
|
||||
|
||||
if (testLoginTokenVo != null) {
|
||||
loginDto.setAccessToken(jwtTokenProvider.createAccessToken(testLoginTokenVo.getUserTokenIdx(), testLoginTokenVo.getUserRole()));
|
||||
loginDto.setRefreshToken(jwtTokenProvider.createRefreshToken(testLoginTokenVo.getUserTokenIdx()));
|
||||
testLoginDto.setAccessToken(jwtTokenProvider.createAccessToken(testLoginTokenVo.getUserTokenIdx(), testLoginTokenVo.getUserRole()));
|
||||
testLoginDto.setRefreshToken(jwtTokenProvider.createRefreshToken(testLoginTokenVo.getUserTokenIdx()));
|
||||
|
||||
loginDto.setResultCode(2001);
|
||||
testLoginDto.setResultCode(2001);
|
||||
} else {
|
||||
loginDto.setResultCode(2003);
|
||||
testLoginDto.setResultCode(2003);
|
||||
}
|
||||
} else {
|
||||
loginDto.setResultCode(2003);
|
||||
testLoginDto.setResultCode(2003);
|
||||
}
|
||||
|
||||
return loginDto;
|
||||
return testLoginDto;
|
||||
}
|
||||
|
||||
public TestLoginDto refreshByToken(String refreshToken) {
|
||||
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int userTokenIdx;
|
||||
try {
|
||||
userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserId(refreshToken));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TestLoginTokenVo tokenVo = testAuthMapper.selectTestUserTokenByUserTokenIdx(userTokenIdx);
|
||||
if (tokenVo == null || tokenVo.getRefreshToken() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshToken.equals(tokenVo.getRefreshToken())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TestLoginDto testLogin = new TestLoginDto();
|
||||
testLogin.setUserTokenIdx(userTokenIdx);
|
||||
testLogin.setAccessToken(jwtTokenProvider.createAccessToken(tokenVo.getUserTokenIdx(), tokenVo.getUserRole()));
|
||||
testLogin.setRefreshToken(jwtTokenProvider.createRefreshToken(tokenVo.getUserTokenIdx()));
|
||||
testLogin.setExpiresAt(Instant.now().plusSeconds(jwtProperties.getRefreshTokenValiditySeconds()));
|
||||
|
||||
testAuthMapper.updateRefreshToken(testLogin);
|
||||
return testLogin;
|
||||
}
|
||||
|
||||
public void clearRefreshToken(String refreshToken) {
|
||||
if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
int userTokenIdx = Integer.parseInt(jwtTokenProvider.getUserId(refreshToken));
|
||||
testAuthMapper.clearRefreshTokenByUserTokenIdx(userTokenIdx);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,18 @@ spring:
|
||||
connection-timeout: 10000
|
||||
idle-timeout: 30000
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: 121.160.234.222
|
||||
port: 3001
|
||||
password: 1qaz2wsx!@
|
||||
|
||||
session:
|
||||
store-type: redis
|
||||
timeout: 7200 # 2시간 (초 단위)
|
||||
redis:
|
||||
namespace: spring:session:alist
|
||||
|
||||
logging:
|
||||
config: classpath:logback-local.xml
|
||||
|
||||
@@ -21,7 +33,9 @@ jwt:
|
||||
refresh-token-validity-seconds: 2592000
|
||||
|
||||
cookie:
|
||||
secure: false # 로컬 개발 환경 (HTTP)
|
||||
secure: true # 로컬 개발 환경 (HTTP)
|
||||
domain: pjt.kr
|
||||
name: ALIST_SESSION
|
||||
|
||||
swagger:
|
||||
login:
|
||||
|
||||
@@ -14,6 +14,18 @@ spring:
|
||||
connection-timeout: 10000
|
||||
idle-timeout: 30000
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT}
|
||||
password: ${REDIS_PASSWORD}
|
||||
|
||||
session:
|
||||
store-type: redis
|
||||
timeout: 7200 # 2시간 (초 단위)
|
||||
redis:
|
||||
namespace: spring:session:alist
|
||||
|
||||
logging:
|
||||
config: classpath:logback-pjt.xml
|
||||
|
||||
@@ -24,6 +36,8 @@ jwt:
|
||||
|
||||
cookie:
|
||||
secure: true # 프로젝트 환경 (HTTPS)
|
||||
domain: pjt.kr
|
||||
name: ALIST_SESSION
|
||||
|
||||
swagger:
|
||||
login:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="com.alist.api.modules.auth.mapper.TestCorsMapper">
|
||||
|
||||
<select id="selectTestCorsAllowedList" resultType="com.alist.api.modules.auth.vo.TestCorsOriginVo">
|
||||
/* TestCorsMapper.selectTestCorsAllowedList */
|
||||
SELECT allowed_origin
|
||||
@@ -11,5 +9,4 @@
|
||||
WHERE del_yn = 1
|
||||
ORDER BY cors_idx
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="com.alist.api.modules.auth.mapper.TestAuthMapper">
|
||||
<update id="updateRefreshToken">
|
||||
@@ -40,4 +38,30 @@
|
||||
from test_user_token
|
||||
where user_api_key = #{userApiKey}
|
||||
</select>
|
||||
|
||||
<select id="selectTestUserTokenByUserTokenIdx" resultType="com.alist.api.modules.auth.vo.TestLoginTokenVo">
|
||||
/*TestLoginMapper.selectTestUserTokenByUserTokenIdx*/
|
||||
select user_token_idx
|
||||
, user_role
|
||||
, refresh_token
|
||||
from test_user_token
|
||||
where user_token_idx = #{userTokenIdx}
|
||||
</select>
|
||||
|
||||
<update id="clearRefreshTokenByUserTokenIdx">
|
||||
/*TestLoginMapper.clearRefreshTokenByUserTokenIdx*/
|
||||
update test_user_token
|
||||
set refresh_token = null
|
||||
, expires_at = null
|
||||
, updated_at = now()
|
||||
where user_token_idx = #{userTokenIdx}
|
||||
</update>
|
||||
|
||||
<update id="updateTestLoginByLastAt">
|
||||
/*TestLoginMapper.updateTestLoginByLastAt*/
|
||||
update test_user
|
||||
set last_login_at = now()
|
||||
, update_at = now()
|
||||
where user_idx = #{userIdx}
|
||||
</update>
|
||||
</mapper>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="com.alist.api.modules.testUser.mapper.TestUserMapper">
|
||||
<insert id="insertTestUserSignup" useGeneratedKeys="true" keyProperty="userIdx">
|
||||
|
||||
Reference in New Issue
Block a user