Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6f9ccf6
🔥 Remove: 불필요한 파일 삭제
usingjun Apr 3, 2025
f10fa0a
♻️ Refactor: token 쿠키 저장 controller
usingjun Apr 3, 2025
53c468b
♻️ Refactor: token 쿠키 저장 service
usingjun Apr 3, 2025
494ed54
🗑️ Chore: 의존성 업데이트
usingjun Apr 3, 2025
fb0c5c7
Merge branch 'develop' into Refactor/JIRA-kan-71-refactor-token-쿠키-저장…
usingjun Apr 3, 2025
b6571c2
🗑️ Chore: 의존성 업데이트
usingjun Apr 6, 2025
8b40fe5
🔨 Build: docker-compose aof & rdb 설정
usingjun Apr 6, 2025
863e48b
🔧 Fix: 버그 수정
usingjun Apr 6, 2025
4c23f33
🗑️ Chore: 의존성 업데이트
usingjun Apr 6, 2025
ac134d5
♻️ Refactor: accessToken 쿠키 저장
usingjun Apr 6, 2025
bf21c86
♻️ Refactor: RefreshToken 재사용 감지 + Rotation 전략
usingjun Apr 6, 2025
5d40a4e
♻️ Refactor: RefreshToken 재사용 감지 + Rotation 전략
usingjun Apr 7, 2025
0020ee0
♻️ Refactor: controller body로 토큰 반환 삭제
usingjun Apr 7, 2025
e33e228
♻️ Refactor: RefreshToken 재사용 감지 + Rotation 전략 테스트
usingjun Apr 7, 2025
c46f601
♻️ Refactor: 예약 송금 서비스단 수정
usingjun Apr 7, 2025
f52f1b1
♻️ Refactor: 예약 송금 테스트 수정
usingjun Apr 7, 2025
1f57e96
♻️ Refactor: 예약 송금 controller 수정
usingjun Apr 7, 2025
8507afb
:sparkles: Feat: 예약 송금 타입, 상태, 엔티티 추가
usingjun Apr 7, 2025
b1c2267
:sparkles: Feat: 예약 송금 리포지토리 추가
usingjun Apr 7, 2025
8f1d222
:sparkles: Feat: 예약 송금 서비스 추가
usingjun Apr 7, 2025
6b755c4
♻️ Refactor: 예약 송금 dto 수정
usingjun Apr 7, 2025
3a03bd4
🔥 Remove: 불필요한 파일 삭제
usingjun Apr 7, 2025
a36e277
♻️ Refactor: SlackNotifier 수정 및 이상감지 알림
usingjun Apr 7, 2025
e3bd0b6
♻️ Refactor: SlackNotifier 수정 및 이상감지 알림
usingjun Apr 7, 2025
2cf67f4
♻️ Refactor: SlackNotifier 수정 및 이상감지 알림
usingjun Apr 7, 2025
f23cb1d
:sparkles: Feat: redis 기반 이상감지 Service 생성
usingjun Apr 7, 2025
409ad86
:sparkles: Feat: 이상감지 dto 생성
usingjun Apr 7, 2025
eece890
:sparkles: Feat: lua 스크립트로 이상탐지 체크
usingjun Apr 7, 2025
e1fc08a
:sparkles: Feat: lua 스크립트로 이상탐지 체크
usingjun Apr 7, 2025
c042020
♻️ Refactor: FraudDetectEvent 값 사용하는 것 수정
usingjun Apr 8, 2025
dfde3ec
🔧 Fix: 순환참조 버그 수정
usingjun Apr 8, 2025
5594ef4
:sparkles: Feat: 예약송금, 이상탐지 테스트 코드
usingjun Apr 8, 2025
aed8718
Merge branch 'develop' into Refactor/JIRA-kan-71-refactor-token-쿠키-저장…
usingjun Apr 8, 2025
f335bb2
🔧 Fix: 버그 수정
usingjun Apr 8, 2025
fcb8e97
🔧 Fix: 버그 수정
usingjun Apr 8, 2025
1b96ef8
🔧 Fix: 순환참조 버그 수정
usingjun Apr 8, 2025
b9c9393
♻️ Refactor: WalletFacade 적용
usingjun Apr 8, 2025
723313e
🗑️ Chore: 의존성 업데이트
usingjun Apr 8, 2025
fdbaf57
✅ Test: 테스트 코드 수정
usingjun Apr 8, 2025
d578af0
♻️ Refactor: 룰 기반 실행 리팩토링
usingjun Apr 8, 2025
fe8ae75
Merge branch 'develop' into Refactor/JIRA-kan-71-refactor-token-쿠키-저장…
usingjun Apr 8, 2025
7c282d5
🎨 Style: 주석 추가
usingjun Apr 8, 2025
449f2a3
🗑️ Chore: 의존성 업데이트
usingjun Apr 8, 2025
7367188
✅ Test: 테스트 코드 수정
usingjun Apr 8, 2025
29115a5
🎨 Style: 주석 수정
usingjun Apr 8, 2025
2991170
🔧 Fix: Lazy Loading 버그 수정
usingjun Apr 9, 2025
5399d58
✅ Test: 테스트 코드 수정
usingjun Apr 9, 2025
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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dependencies {
implementation 'io.projectreactor.netty:reactor-netty:1.1.22' // Reactor Netty 의존성
implementation 'com.fasterxml.jackson.core:jackson-databind' // JSON 파싱을 위한 의존성
implementation 'com.google.code.gson:gson:2.10.1' // JSON 데이터를 다룰 수 있는 클래스
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' // MacOS에서 DNS 관련 네이티브 라이브러리
}

tasks.named('test') {
Expand Down
11 changes: 10 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ services:
- "6379:6379"
volumes:
- redis-data:/data
command: [ "redis-server", "--appendonly", "yes" ]
command: [
"redis-server",
"--save", "900 1", # 900초(15분)마다 최소 1개 이상의 변경이 있으면 스냅샷 저장
"--save", "300 10", # 300초(5분)마다 최소 10개 이상의 변경이 있으면 저장
"--save", "60 1000", # 60초마다 최소 1000개의 변경사항이 있으면 저장
"--appendonly", "yes", # AOF 활성화
"--appendfsync", "everysec", # 1초마다 디스크에 기록
"--auto-aof-rewrite-percentage", "100", # AOF 크기가 100% 커지면 리라이트
"--auto-aof-rewrite-min-size", "64mb" # AOF 크기가 최소 64MB일 때 리라이트
]

# 🔹 PostgreSQL (Spring Boot가 사용하는 DB)
postgres:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
package bumblebee.xchangepass.domain.refresh.controller;

import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenRequest;
import bumblebee.xchangepass.domain.refresh.service.RefreshTokenService;
import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse;
import bumblebee.xchangepass.global.error.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

Expand Down Expand Up @@ -43,8 +45,20 @@ public class RefreshTokenController {
})
@ResponseStatus(HttpStatus.OK)
@PostMapping("/token-refresh")
public void tokenRefresh(@RequestBody @Valid RefreshTokenRequest request) {
refreshTokenService.refreshToken(request.refreshToken());
public ResponseEntity<RefreshTokenResponse> tokenRefresh(@CookieValue(value = "refreshToken", required = false) String refreshToken) {
// refreshToken이 없다면 예외 처리
if (refreshToken == null) {
throw ErrorCode.REFRESH_TOKEN_INVALID.commonException();
}

// 새 토큰 재발급
RefreshTokenResponse response = refreshTokenService.refreshToken(refreshToken);

ResponseCookie refreshCookie = refreshTokenService.saveRefreshToken(response);

return ResponseEntity.ok()
.header("Set-Cookie", refreshCookie.toString())
.build();
}

}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,9 @@ public boolean isValidRefreshToken(Long userId, String refreshToken) {
String storedToken = (String) jsonRedisTemplate.opsForValue().get("refresh_token:" + userId);
return storedToken != null && storedToken.equals(refreshToken);
}

public String getRefreshToken(Long userId) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

일관성을 위해서 주석을 추가해주시면 감사하겠습니다

String key = "refresh_token:" + userId;
return (String) jsonRedisTemplate.opsForValue().get(key);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import bumblebee.xchangepass.global.security.jwt.JwtProvider;
import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;

@Service
Expand All @@ -27,10 +28,18 @@ public RefreshTokenResponse refreshToken(final String refreshToken) {
// Redis에서 사용자 ID 조회
Long userId = refreshTokenRepository.getUserIdFromRefreshToken(refreshToken);

// Redis에 저장된 토큰과 비교 → 재사용 감지
String storedToken = refreshTokenRepository.getRefreshToken(userId);
Copy link
Collaborator

Choose a reason for hiding this comment

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

이 로직으로 볼 때 Redis에 전에 사용했던 refreshToken을 누적시키는 것 처럼 보이는데 redis의 용량은 한정적인데 방안이 있을까요?

Copy link
Collaborator Author

@usingjun usingjun Apr 8, 2025

Choose a reason for hiding this comment

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

RefreshToken Rotate방식을 적용해서 RefreshToken을 사용하면 레디스에서 삭제후 재발급하는 방식을 사용하고있습니다.

if (storedToken == null || !storedToken.equals(refreshToken)) {
// 이미 사용된 토큰 or 위조된 토큰 → 재사용 시도
refreshTokenRepository.deleteRefreshToken(userId); // Redis 강제 삭제

        throw ErrorCode.REFRESH_TOKEN_INVALID.commonException();

}


if (storedToken == null || !storedToken.equals(refreshToken)) {
// 이미 사용된 토큰 or 위조된 토큰 → 재사용 시도
refreshTokenRepository.deleteRefreshToken(userId); // Redis 강제 삭제

throw ErrorCode.REFRESH_TOKEN_INVALID.commonException();
}

// 새로운 Access Token 생성
String newAccessToken = jwtProvider.generateAccessToken(userId);

// 새로운 Refresh Token 생성 후 Redis에 저장 (기존 것은 자동 삭제됨)
String newRefreshToken = jwtProvider.generateRefreshToken(userId);
refreshTokenRepository.saveRefreshToken(newRefreshToken, userId);

Expand All @@ -40,6 +49,16 @@ public RefreshTokenResponse refreshToken(final String refreshToken) {
.build();
}

public ResponseCookie saveRefreshToken(RefreshTokenResponse response) {
return ResponseCookie.from("refreshToken", response.refreshToken())
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(60 * 60 * 24 * 7)
.sameSite("Strict")
Copy link
Collaborator

Choose a reason for hiding this comment

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

sameStite 설정의 경우 https 도메인이 아니면 의미가 없는데 대체 방안이 있을까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

금융서비스 특징상 https를 사용할 것이라 생각해서 만들었습니다만 만약 아니라면
.sameSite("Lax")를 적용해서 최소한의 CSRF 방어하겠습니다

.build();
}

/**
* refresh token 검증
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package bumblebee.xchangepass.domain.user.login;

import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse;
import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest;
import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
Expand All @@ -11,10 +11,14 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequiredArgsConstructor
@Tag(name = "Login", description = "Login API")
Expand All @@ -40,8 +44,16 @@ public class LoginController {
})
@ResponseStatus(HttpStatus.OK)
@PostMapping("/login")
public LoginResponse memberLogin(@RequestBody @Valid LoginRequest loginRequest) {
return loginService.login(loginRequest);
public ResponseEntity<RefreshTokenResponse> memberLogin(@RequestBody @Valid LoginRequest loginRequest) {
RefreshTokenResponse response = loginService.login(loginRequest);

ResponseCookie accessCookie = loginService.saveAccessToken(response.accessToken());
ResponseCookie refreshCookie = loginService.saveRefreshToken(response);

return ResponseEntity.ok()
.header("Set-Cookie", accessCookie.toString())
Copy link
Collaborator

Choose a reason for hiding this comment

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

access 토큰도 쿠키로 반환하는 것일까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

네 accessToken을 localStorage에 저장하게 되면 XSS 공격 시 토큰이 노출될 수 있는데
이를 HttpOnly 쿠키에 저장하면 XSS에 더 안전하게 됩니다.

.header("Set-Cookie", refreshCookie.toString())
Comment on lines +54 to +55
Copy link
Collaborator

Choose a reason for hiding this comment

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

대체적으로 access 로컬 / refresh 쿠키로 저장하는게 일반적인데 access도 쿠키로 저장하는 이유가 있을까요?

.build();
}

@Operation(summary = "로그아웃", description = "로그아웃합니다.")
Expand All @@ -55,8 +67,20 @@ public LoginResponse memberLogin(@RequestBody @Valid LoginRequest loginRequest)
})
@ResponseStatus(HttpStatus.OK)
@PostMapping("/logout")
public void logout(@RequestHeader("Authorization") String refreshToken) {
public ResponseEntity<RefreshTokenResponse> logout(@RequestHeader("Authorization") String refreshToken) {
if (refreshToken.startsWith("Bearer ")) {
refreshToken = refreshToken.substring(7);
}

// Refresh Token 삭제
loginService.logout(refreshToken);

ResponseCookie expiredRefresh = loginService.deleteRefreshToken();
ResponseCookie expiredAccess = loginService.deleteAccessToken();

return ResponseEntity.ok()
.header("Set-Cookie", expiredRefresh.toString())
.header("Set-Cookie", expiredAccess.toString())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package bumblebee.xchangepass.domain.user.login;

import bumblebee.xchangepass.domain.refresh.dto.RefreshTokenResponse;
import bumblebee.xchangepass.domain.refresh.repository.RefreshTokenRepository;
import bumblebee.xchangepass.domain.user.login.dto.response.UserLoginResponse;
import bumblebee.xchangepass.domain.user.service.UserService;
import bumblebee.xchangepass.global.error.ErrorCode;
import bumblebee.xchangepass.global.security.jwt.JwtProvider;
import bumblebee.xchangepass.domain.user.login.dto.request.LoginRequest;
import bumblebee.xchangepass.domain.user.login.dto.response.LoginResponse;
import bumblebee.xchangepass.domain.refresh.repository.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseCookie;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -25,7 +26,7 @@ public class LoginService{
private final RefreshTokenRepository refreshTokenRepository;

@Transactional
public LoginResponse login(final LoginRequest request) {
public RefreshTokenResponse login(final LoginRequest request) {
// 사용자 정보 조회
UserLoginResponse userInfo = userService.readUserByUserEmail(request.userEmail());

Expand All @@ -40,12 +41,33 @@ public LoginResponse login(final LoginRequest request) {
String refreshToken = jwtProvider.generateRefreshToken(userInfo.userId());
refreshTokenRepository.saveRefreshToken(refreshToken, userInfo.userId());

return LoginResponse.builder()
return RefreshTokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}

public ResponseCookie saveAccessToken(String accessToken) {
return ResponseCookie.from("accessToken", accessToken)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(60 * 15) // 15분
.sameSite("Strict")
.build();
}

public ResponseCookie saveRefreshToken(RefreshTokenResponse response) {
// 쿠키 설정
return ResponseCookie.from("refreshToken", response.refreshToken())
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(60 * 60 * 24 * 7) // 7일
.sameSite("Strict")
.build();
}

public void logout(String refreshToken) {
// "Bearer " 접두사 제거
if (refreshToken.startsWith("Bearer ")) {
Expand All @@ -55,4 +77,25 @@ public void logout(String refreshToken) {
Long userId = refreshTokenRepository.getUserIdFromRefreshToken(refreshToken);
refreshTokenRepository.deleteRefreshToken(userId);
}

public ResponseCookie deleteAccessToken() {
return ResponseCookie.from("accessToken", "")
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(0) // 즉시 만료
.sameSite("Strict")
.build();
}


public ResponseCookie deleteRefreshToken() {
return ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(0) // 쿠키 만료
.sameSite("Strict")
.build();
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import bumblebee.xchangepass.domain.wallet.balance.entity.WalletBalance;
import bumblebee.xchangepass.domain.wallet.balance.repository.WalletBalanceRepository;
import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectEvent;
import bumblebee.xchangepass.domain.wallet.fraud.service.FraudDetectionService;
import bumblebee.xchangepass.domain.wallet.transaction.entity.WalletTransactionType;
import bumblebee.xchangepass.domain.wallet.transaction.service.WalletTransactionService;
import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet;
Expand All @@ -11,6 +13,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;

Expand All @@ -20,6 +23,7 @@ public class WalletBalanceService {

private final WalletBalanceRepository balanceRepository;
private final WalletTransactionService transactionService;
private final FraudDetectionService fraudDetectionService;

public void createBalance(Wallet wallet, Currency currency) {
if (balanceRepository.existsByCurrency(wallet.getWalletId(), currency)) {
Expand Down Expand Up @@ -75,6 +79,13 @@ public void withdrawBalance(WalletBalance balance, BigDecimal amount) {
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

walletbalance 서버스도 다른 서버스와 같이 간단하게 주석을 추가해주실 수 있을까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

넵!

public void transferBalance(WalletBalance fromBalance, WalletBalance toBalance, BigDecimal amount) {
fraudDetectionService.detect(new FraudDetectEvent(
fromBalance.getWallet().getUser().getUserId(),
amount,
LocalDateTime.now(),
null
));

fromBalance.subtractBalance(amount);
toBalance.addBalance(amount);
balanceRepository.save(fromBalance);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package bumblebee.xchangepass.domain.wallet.fraud.service;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public record FraudDetectEvent(
Long userId,
BigDecimal amount,
LocalDateTime timestamp,
String detail
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package bumblebee.xchangepass.domain.wallet.fraud.service;

import bumblebee.xchangepass.domain.wallet.transaction.consumer.SlackNotifier;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FraudDetectionService {

private final RedisFraudStorageService redisFraudStorage;
private final FraudRuleEvaluator ruleEvaluator;
private final SlackNotifier slackNotifier;

public void detect(FraudDetectEvent event) {
redisFraudStorage.store(event.userId(), event.amount());

boolean isFraud = ruleEvaluator.isSuspicious(event.userId(), event.amount());

if (isFraud) {
String reason = ruleEvaluator.getLastDetectedReason();
slackNotifier.notifyFraud(new FraudDetectEvent(
event.userId(),
event.amount(),
event.timestamp(),
reason
));
}
}
}
Loading