From f2fe4f10c1cd0201c2a60fafd223abbd74d9f907 Mon Sep 17 00:00:00 2001 From: sdw086 Date: Tue, 3 Mar 2026 15:16:59 +0900 Subject: [PATCH] =?UTF-8?q?[SSO]=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + CLAUDE.md | 162 ------------------ build.gradle | 4 + .../alist/api/common/utils/SessionUtil.java | 33 ++++ .../alist/api/config/RedisSessionConfig.java | 37 ++++ .../com/alist/api/config/SecurityConfig.java | 2 +- .../api/config/filter/DynamicCorsFilter.java | 4 +- .../config/jwt/JwtAuthenticationFilter.java | 30 ++-- .../api/config/jwt/JwtTokenProvider.java | 2 +- .../api/modules/auth/AuthController.java | 110 +++++++++++- .../api/modules/auth/TestAuthController.java | 42 ++--- .../modules/auth/mapper/TestAuthMapper.java | 7 + .../modules/auth/service/TestAuthService.java | 94 +++++++--- src/main/resources/application-local.yaml | 16 +- src/main/resources/application-pjt.yaml | 14 ++ .../resources/mapper/auth/TestCorsMapper.xml | 5 +- .../resources/mapper/auth/TestLoginMapper.xml | 38 +++- .../mapper/testUser/TestUserMapper.xml | 4 +- 18 files changed, 363 insertions(+), 243 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 src/main/java/com/alist/api/common/utils/SessionUtil.java create mode 100644 src/main/java/com/alist/api/config/RedisSessionConfig.java diff --git a/.gitignore b/.gitignore index df5f346..fc63f70 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ out/ ### Claude Code ### .claude/ + +AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c3d47c3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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` 래퍼 사용 -- 응답 코드는 `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/{파일경로}` diff --git a/build.gradle b/build.gradle index b27da4a..dba8f3a 100644 --- a/build.gradle +++ b/build.gradle @@ -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') { diff --git a/src/main/java/com/alist/api/common/utils/SessionUtil.java b/src/main/java/com/alist/api/common/utils/SessionUtil.java new file mode 100644 index 0000000..15ed22c --- /dev/null +++ b/src/main/java/com/alist/api/common/utils/SessionUtil.java @@ -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()); + } +} diff --git a/src/main/java/com/alist/api/config/RedisSessionConfig.java b/src/main/java/com/alist/api/config/RedisSessionConfig.java new file mode 100644 index 0000000..97a7d1b --- /dev/null +++ b/src/main/java/com/alist/api/config/RedisSessionConfig.java @@ -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; + } +} diff --git a/src/main/java/com/alist/api/config/SecurityConfig.java b/src/main/java/com/alist/api/config/SecurityConfig.java index 1e52dd8..1fda778 100644 --- a/src/main/java/com/alist/api/config/SecurityConfig.java +++ b/src/main/java/com/alist/api/config/SecurityConfig.java @@ -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()) diff --git a/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java b/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java index d136a2d..514f0f3 100644 --- a/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java +++ b/src/main/java/com/alist/api/config/filter/DynamicCorsFilter.java @@ -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"); diff --git a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java index b9c9f5a..70d16d7 100644 --- a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java @@ -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; } } diff --git a/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java b/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java index ed4113f..90ad555 100644 --- a/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java +++ b/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java @@ -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)) diff --git a/src/main/java/com/alist/api/modules/auth/AuthController.java b/src/main/java/com/alist/api/modules/auth/AuthController.java index 1031846..7c0d3f1 100644 --- a/src/main/java/com/alist/api/modules/auth/AuthController.java +++ b/src/main/java/com/alist/api/modules/auth/AuthController.java @@ -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>> 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>> loginChecked(HttpServletRequest request) { + Map 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>> 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 result = new HashMap<>(); + result.put("message", "로그아웃되었습니다."); + + return ApiResponse.entity(result, ApiResponseCode.CODE_200); + } } diff --git a/src/main/java/com/alist/api/modules/auth/TestAuthController.java b/src/main/java/com/alist/api/modules/auth/TestAuthController.java index 4fcffae..aaceb2a 100644 --- a/src/main/java/com/alist/api/modules/auth/TestAuthController.java +++ b/src/main/java/com/alist/api/modules/auth/TestAuthController.java @@ -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> testUserLogin( - @Valid @RequestBody TestLoginForm testLoginForm, - HttpServletResponse response) { + public ResponseEntity> 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); diff --git a/src/main/java/com/alist/api/modules/auth/mapper/TestAuthMapper.java b/src/main/java/com/alist/api/modules/auth/mapper/TestAuthMapper.java index 10e508e..bb726d2 100644 --- a/src/main/java/com/alist/api/modules/auth/mapper/TestAuthMapper.java +++ b/src/main/java/com/alist/api/modules/auth/mapper/TestAuthMapper.java @@ -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); } diff --git a/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java b/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java index cb6e6f4..765902c 100644 --- a/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java +++ b/src/main/java/com/alist/api/modules/auth/service/TestAuthService.java @@ -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) { + } } } \ No newline at end of file diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 4611f21..0d524ef 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -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: diff --git a/src/main/resources/application-pjt.yaml b/src/main/resources/application-pjt.yaml index 2f3390b..4617b6b 100644 --- a/src/main/resources/application-pjt.yaml +++ b/src/main/resources/application-pjt.yaml @@ -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: diff --git a/src/main/resources/mapper/auth/TestCorsMapper.xml b/src/main/resources/mapper/auth/TestCorsMapper.xml index 8cf69b5..da88620 100644 --- a/src/main/resources/mapper/auth/TestCorsMapper.xml +++ b/src/main/resources/mapper/auth/TestCorsMapper.xml @@ -1,9 +1,7 @@ - + - - \ No newline at end of file diff --git a/src/main/resources/mapper/auth/TestLoginMapper.xml b/src/main/resources/mapper/auth/TestLoginMapper.xml index 83d8018..29a4d57 100644 --- a/src/main/resources/mapper/auth/TestLoginMapper.xml +++ b/src/main/resources/mapper/auth/TestLoginMapper.xml @@ -1,15 +1,13 @@ - + /*TestLoginMapper.selectTestLogin*/ update test_user_token set refresh_token = #{refreshToken} - , expires_at = #{expiresAt} - , updated_at = now() + , expires_at = #{expiresAt} + , updated_at = now() where user_token_idx = #{userTokenIdx} @@ -35,9 +33,35 @@ + + + + + /*TestLoginMapper.clearRefreshTokenByUserTokenIdx*/ + update test_user_token + set refresh_token = null + , expires_at = null + , updated_at = now() + where user_token_idx = #{userTokenIdx} + + + + /*TestLoginMapper.updateTestLoginByLastAt*/ + update test_user + set last_login_at = now() + , update_at = now() + where user_idx = #{userIdx} + diff --git a/src/main/resources/mapper/testUser/TestUserMapper.xml b/src/main/resources/mapper/testUser/TestUserMapper.xml index a0d5b56..bb47054 100644 --- a/src/main/resources/mapper/testUser/TestUserMapper.xml +++ b/src/main/resources/mapper/testUser/TestUserMapper.xml @@ -1,7 +1,5 @@ - +