Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8c40eea
feat: RefreshToken 저장소를 Redis에서 MySQL로 마이그레이션
bbbang105 Jan 20, 2026
93a1839
feat: 토큰 재발급 API에 원자적 업데이트 및 토큰 검증 추가
bbbang105 Jan 20, 2026
800f5af
feat: ClientInfoExtractor 유틸 추가 및 토큰에 IP/UserAgent 저장
bbbang105 Jan 20, 2026
cab83dc
feat: 회원 탈퇴 시 RefreshToken revoke 처리
bbbang105 Jan 20, 2026
b6450c4
refactor: JwtUtil에 expiryAt 계산 메서드 추출
bbbang105 Jan 20, 2026
0baaa04
[feat] : RefreshToken Cleanup Scheduler 추가
bbbang105 Jan 20, 2026
1f0d42c
[test] : DB 통합 테스트를 위한 Testcontainers 설정 추가
bbbang105 Jan 20, 2026
edc350b
[test] : RefreshToken 관련 테스트 코드 추가
bbbang105 Jan 20, 2026
548c462
[ci] : commit-labeler에 ci 라벨 추가
bbbang105 Jan 20, 2026
8debc82
[ci] : prod-cicd PR 머지 시에만 실행되도록 수정
bbbang105 Jan 20, 2026
f9c2d21
[chore] : open-api JSON 파일 gitignore 처리
bbbang105 Jan 20, 2026
d98a809
[ci] : PR Auto Assign 워크플로우 추가
bbbang105 Jan 20, 2026
4a881a4
[chore] : CODEOWNERS에 팀원 추가
bbbang105 Jan 20, 2026
bbafaa0
[chore] : static/docs 디렉토리 gitkeep 추가
bbbang105 Jan 20, 2026
49bcdb4
fix: RefreshToken 관련 @Transactional 누락 수정
bbbang105 Jan 20, 2026
7324607
fix: QueryDSL bulk update 시 updatedDate 수동 갱신 추가
bbbang105 Jan 20, 2026
ca59967
fix: 토큰 재발급 API에서 JwtFilter 제외
bbbang105 Jan 20, 2026
a3f7aba
fix: 로그아웃 API에서 JwtFilter 제외
bbbang105 Jan 20, 2026
2b8bafc
refactor: LocalDateTime.now() 중복 호출 제거
bbbang105 Jan 21, 2026
c2412b3
fix: TokenService에 validateToken() 추가
bbbang105 Jan 21, 2026
9bad834
chore: 미사용 DistributedLock 관련 코드 제거
bbbang105 Jan 21, 2026
4209ea6
fix: redisson 의존성 제거
bbbang105 Jan 21, 2026
6663547
feat: 로컬 yaml db 설정 변경
bbbang105 Jan 21, 2026
6171a67
fix: local 프로파일 JPA 설정 개선
bbbang105 Jan 21, 2026
e1c5eb7
chore: 최신 버전을 사용한다
bbbang105 Jan 21, 2026
cb0d50e
fix: gitkeep을 제거하지 않도록 변경한다
bbbang105 Jan 21, 2026
69be202
Merge branch 'develop' into feature/#315/refresh-token
bbbang105 Jan 22, 2026
b4d4643
fix: conflict를 해결한다
bbbang105 Jan 22, 2026
a3d22c0
fix: 슬래시를 제거한다
bbbang105 Jan 22, 2026
54b2d86
refactor: Service 레이어에서 HttpServletRequest 의존성을 제거한다
bbbang105 Jan 25, 2026
e89f3b0
feat: RefreshToken에 userType 필드를 추가한다
bbbang105 Jan 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @bbbang105
* @bbbang105 @anxi01
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
addReviewers: true
addAssignees: author
reviewers:
- bbang105
- bbbang105
- anxi01
15 changes: 15 additions & 0 deletions .github/workflows/auto-assign.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Auto Assign

on:
pull_request:
types: [opened, ready_for_review]

jobs:
assign:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: kentaro-m/auto-assign-action@v2.0.1
with:
configuration-path: '.github/auto-assign-config.yaml'
23 changes: 13 additions & 10 deletions .github/workflows/commit-labeler.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,19 @@ jobs:
for (const c of commits.data) {
const msg = c.commit.message.toLowerCase();
if (msg.includes("[feat]")) labels.add("🚀 feat");
if (msg.includes("[fix]")) labels.add("🚨 fix");
if (msg.includes("[docs]")) labels.add("📄 docs");
if (msg.includes("[style]")) labels.add("🌱 style");
if (msg.includes("[refactor]")) labels.add("🔄 refactor");
if (msg.includes("[chore]")) labels.add("⚒️ chore");
if (msg.includes("[hotfix]")) labels.add("🛟 hotfix");
if (msg.includes("[release]")) labels.add("💫 release");
if (msg.includes("[rename]")) labels.add("🎫 rename");
if (msg.includes("[remove]")) labels.add("✂️ remove");
if (msg.includes("feat")) labels.add("🚀 feat");
if (msg.includes("fix")) labels.add("🚨 fix");
if (msg.includes("docs")) labels.add("📄 docs");
if (msg.includes("style")) labels.add("🌱 style");
if (msg.includes("refactor")) labels.add("🔄 refactor");
if (msg.includes("test")) labels.add("✅ test");
if (msg.includes("perf")) labels.add("⚡️ perf");
if (msg.includes("chore")) labels.add("⚒️ chore");
if (msg.includes("ci")) labels.add("🔧 ci");
if (msg.includes("hotfix")) labels.add("🛟 hotfix");
if (msg.includes("release")) labels.add("💫 release");
if (msg.includes("rename")) labels.add("🎫 rename");
if (msg.includes("remove")) labels.add("✂️ remove");
}
if (labels.size > 0) {
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/prod-cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ permissions:
jobs:
build-and-push:
name: Build and Push to ECR
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
environment: prod
steps:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ out/
### Claude Code ###
.claude
/docs

### Auto-generated files ###
src/main/resources/static/docs/open-api-3.0.1.json
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ dependencies {

// Jsoup
implementation 'org.jsoup:jsoup:1.21.2'

// Testcontainers
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
testImplementation 'org.testcontainers:mysql:1.20.1'
}

// QueryDSL 디렉토리
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
package side.onetime.auth.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import side.onetime.auth.dto.GoogleUserInfo;
import side.onetime.auth.dto.KakaoUserInfo;
import side.onetime.auth.dto.NaverUserInfo;
Expand All @@ -17,13 +25,9 @@
import side.onetime.domain.User;
import side.onetime.repository.RefreshTokenRepository;
import side.onetime.repository.UserRepository;
import side.onetime.util.ClientInfoExtractor;
import side.onetime.util.JwtUtil;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
Expand All @@ -38,6 +42,7 @@ public class OAuthLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHand
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final ClientInfoExtractor clientInfoExtractor;

/**
* OAuth2 인증 성공 처리 메서드.
Expand Down Expand Up @@ -141,12 +146,27 @@ private void handleNewUser(HttpServletRequest request, HttpServletResponse respo
private void handleExistingUser(HttpServletRequest request, HttpServletResponse response, User user) throws IOException {
Long userId = user.getId();
String browserId = jwtUtil.hashUserAgent(request.getHeader("User-Agent"));
String userIp = clientInfoExtractor.extractClientIp(request);
String userAgent = clientInfoExtractor.extractUserAgent(request);

// 기존 브라우저의 ACTIVE 토큰 revoke
refreshTokenRepository.revokeByUserIdAndBrowserId(userId, browserId);

// 새 토큰 생성
String jti = UUID.randomUUID().toString();
String accessToken = jwtUtil.generateAccessToken(userId, "USER");
String refreshTokenValue = jwtUtil.generateRefreshToken(userId, browserId, jti);

LocalDateTime now = LocalDateTime.now();
LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now);

String accessToken = jwtUtil.generateAccessToken(userId,"USER");
String refreshToken = jwtUtil.generateRefreshToken(userId, browserId);
refreshTokenRepository.save(new RefreshToken(userId, browserId, refreshToken));
RefreshToken refreshToken = RefreshToken.create(
userId, jti, browserId, refreshTokenValue,
now, expiryAt, userIp, userAgent
);
refreshTokenRepository.save(refreshToken);

String redirectUri = String.format(ACCESS_TOKEN_REDIRECT_URI, "true", accessToken, refreshToken);
String redirectUri = String.format(ACCESS_TOKEN_REDIRECT_URI, "true", accessToken, refreshTokenValue);
getRedirectStrategy().sendRedirect(request, response, redirectUri);
}
}
12 changes: 8 additions & 4 deletions src/main/java/side/onetime/controller/TokenController.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package side.onetime.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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 jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import side.onetime.dto.token.request.ReissueTokenRequest;
import side.onetime.dto.token.response.ReissueTokenResponse;
import side.onetime.global.common.ApiResponse;
Expand All @@ -24,13 +26,15 @@ public class TokenController {
* 액세스 토큰 재발행 API.
*
* @param reissueAccessTokenRequest 리프레쉬 토큰을 포함한 요청 객체
* @param httpRequest HttpServletRequest (IP, User-Agent 추출용)
* @return 재발행된 액세스 토큰과 리프레쉬 토큰을 포함하는 응답 객체
*/
@PostMapping("/action-reissue")
public ResponseEntity<ApiResponse<ReissueTokenResponse>> reissueToken(
@Valid @RequestBody ReissueTokenRequest reissueAccessTokenRequest) {
@Valid @RequestBody ReissueTokenRequest reissueAccessTokenRequest,
HttpServletRequest httpRequest) {

ReissueTokenResponse reissueTokenResponse = tokenService.reissueToken(reissueAccessTokenRequest);
ReissueTokenResponse reissueTokenResponse = tokenService.reissueToken(reissueAccessTokenRequest, httpRequest);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service 레이어에서 httpRequest를 인자로 받고 있어 HTTP 요청과 비즈니스 로직이 강결합이 될 수 있을 것 같아요!
Controller에서 HttpRequest의 userAgent, clientIp를 추출해서 Service 레이어에 넘겨주면 해소할 수 있을 것 같습니다

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분을 놓쳤네요 감사합니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return ApiResponse.onSuccess(SuccessStatus._REISSUE_TOKENS, reissueTokenResponse);
}
}
32 changes: 26 additions & 6 deletions src/main/java/side/onetime/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
package side.onetime.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import side.onetime.domain.enums.GuideType;
import side.onetime.dto.user.request.*;
import side.onetime.dto.user.response.*;
import side.onetime.dto.user.request.CreateGuideViewLogRequest;
import side.onetime.dto.user.request.LogoutUserRequest;
import side.onetime.dto.user.request.OnboardUserRequest;
import side.onetime.dto.user.request.UpdateUserPolicyAgreementRequest;
import side.onetime.dto.user.request.UpdateUserProfileRequest;
import side.onetime.dto.user.request.UpdateUserSleepTimeRequest;
import side.onetime.dto.user.response.GetGuideViewLogResponse;
import side.onetime.dto.user.response.GetUserPolicyAgreementResponse;
import side.onetime.dto.user.response.GetUserProfileResponse;
import side.onetime.dto.user.response.GetUserSleepTimeResponse;
import side.onetime.dto.user.response.OnboardUserResponse;
import side.onetime.global.common.ApiResponse;
import side.onetime.global.common.status.SuccessStatus;
import side.onetime.service.UserService;
Expand All @@ -28,9 +47,10 @@ public class UserController {
*/
@PostMapping("/onboarding")
public ResponseEntity<ApiResponse<OnboardUserResponse>> onboardUser(
@Valid @RequestBody OnboardUserRequest onboardUserRequest) {
@Valid @RequestBody OnboardUserRequest onboardUserRequest,
HttpServletRequest httpRequest) {

OnboardUserResponse onboardUserResponse = userService.onboardUser(onboardUserRequest);
OnboardUserResponse onboardUserResponse = userService.onboardUser(onboardUserRequest, httpRequest);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 위 리뷰와 마찬가지로
Controller에서 HttpRequest의 userAgent, clientIp를 추출해서 Service 레이어에 넘겨주면 해소할 수 있을 것 같습니다~

return ApiResponse.onSuccess(SuccessStatus._ONBOARD_USER, onboardUserResponse);
}

Expand Down
Loading
Loading