Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 18 additions & 5 deletions MR_config/local/application.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ spring:
# 공통 JPA 및 하이버네이트 구동 설정
jpa:
hibernate:
ddl-auto: ${SPRING_JPA_DDL_AUTO:update} # 배포 환경(prod)에선 validate로 덮어씌워짐
ddl-auto: ${SPRING_JPA_DDL_AUTO:validate}
show-sql: true
properties:
hibernate:
Expand All @@ -29,6 +29,16 @@ spring:
max-file-size: 20MB
max-request-size: 20MB

data:
redis:
host: ${SPRING_DATA_REDIS_HOST:localhost}
port: ${SPRING_DATA_REDIS_PORT:6379}
connect-timeout: ${SPRING_DATA_REDIS_CONNECT_TIMEOUT:2s}
timeout: ${SPRING_DATA_REDIS_TIMEOUT:2s}

flyway:
enabled: false

# Spring Security 및 JWT 인증 설정
jwt:
secret: ${JWT_SECRET_KEY}
Expand All @@ -37,15 +47,18 @@ jwt:

# 소셜 로그인 API 연동
oauth:
frontend-redirect-uri: ${OAUTH_FRONTEND_REDIRECT_URI:http://localhost:5173/oauth/callback}
temp-code-ttl: ${OAUTH_TEMP_CODE_TTL:2m}
cookie:
secure: ${OAUTH_COOKIE_SECURE:false}

kakao:
client-id: ${KAKAO_CLIENT_ID:}
client-secret: ${KAKAO_CLIENT_SECRET:}
redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080/login/oauth2/code/kakao}

google:
client-id: ${GOOGLE_CLIENT_ID:}
client-secret: ${GOOGLE_CLIENT_SECRET:}
redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080/login/oauth2/code/google}

# AI 도입 및 AWS S3 공통 구조
external:
Expand All @@ -65,7 +78,7 @@ aws:
# 내부 AI 분석 서버 설정
ai:
internal:
base-url: ${AI_INTERNAL_BASE_URL:https://ai.musereview.site}
base-url: ${AI_INTERNAL_BASE_URL}
connect-timeout: ${AI_INTERNAL_CONNECT_TIMEOUT:5s}
read-timeout: ${AI_INTERNAL_READ_TIMEOUT:60s}
endpoints:
Expand All @@ -74,4 +87,4 @@ ai:
# 프로필 관련 공통 설정
app:
profile:
default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:}
default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png}
4 changes: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ dependencies {

// JWT
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// PostgreSQL 18, JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.flywaydb:flyway-core'
runtimeOnly 'org.postgresql:postgresql'

// Lombok , DevTools
Expand Down Expand Up @@ -77,4 +79,4 @@ tasks.register('copyPrivateConfig', Copy) {

tasks.named('processResources') {
dependsOn 'copyPrivateConfig'
}
}
12 changes: 12 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,17 @@ services:
volumes:
- musereview-postgres-data:/var/lib/postgresql

redis:
image: redis:7.4-alpine
container_name: musereview-redis
ports:
- "127.0.0.1:6379:6379"
command: ["redis-server", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5

volumes:
musereview-postgres-data:
15 changes: 15 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,27 @@ services:
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
APP_PROFILE_DEFAULTIMAGEURL: ${DEFAULT_PROFILE_IMAGE_URL:?DEFAULT_PROFILE_IMAGE_URL is required}
AI_INTERNAL_BASE_URL: ${AI_INTERNAL_BASE_URL:?AI_INTERNAL_BASE_URL is required}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
ports:
- "127.0.0.1:8080:8080"

depends_on:
analysis:
condition: service_healthy
redis:
condition: service_healthy

redis:
image: redis:7.4-alpine
container_name: musereview-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5

analysis:
build:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@JsonIgnoreProperties(ignoreUnknown = true)
public record GoogleUserResponse(
@JsonAlias({"sub", "id"}) String id,
String aud,
String email,
String name,
String picture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ public enum AuthErrorStatus implements BaseCode {

// 외부 소셜 연동 오류
OAUTH_CLIENT_ERROR(HttpStatus.UNAUTHORIZED, "AUTH_401_04", "소셜 로그인 인증에 실패했거나 유효하지 않은 소셜 액세스 토큰입니다."),
OAUTH_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "AUTH_503_01", "소셜 인증 제공자(카카오/구글) 서버와의 통신에 실패했습니다.");
OAUTH_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "AUTH_503_01", "소셜 인증 제공자(카카오/구글) 서버와의 통신에 실패했습니다."),
TEMP_CODE_STORE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AUTH_503_02", "로그인 인증 정보를 일시적으로 처리할 수 없습니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
}
49 changes: 12 additions & 37 deletions src/main/java/com/mr/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,8 @@
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Service
@RequiredArgsConstructor
Expand All @@ -33,6 +30,7 @@ public class AuthService {
private final JwtTokenProvider tokenProvider;
private final OAuthClientService oAuthClientService;
private final AuthTransactionService authTransactionService;
private final OAuthTempCodeStore tempCodeStore;

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) {
Expand Down Expand Up @@ -61,16 +59,13 @@ public String generateTempCodeByCode(SocialType socialType, String code, String
OAuthUserInfo userInfo = oAuthClientService.getUserInfoByCode(socialType, code, redirectUri);
AuthTransactionService.SocialLoginPrepareResult prepareResult = authTransactionService.prepareSocialLogin(socialType, userInfo);

cleanExpiredTempCodes();
String tempCode = UUID.randomUUID().toString();
tempCodeStore.put(tempCode, new TempExchangeData(
tempCodeStore.save(tempCode, new OAuthTempCodeStore.TempExchangeData(
prepareResult.userId(),
socialType,
prepareResult.socialId(),
prepareResult.profileImgUrl(),
deviceInfo,
prepareResult.isNewUser(),
LocalDateTime.now().plusSeconds(TEMP_CODE_EXPIRATION_SECONDS)
deviceInfo
));
return tempCode;
}
Expand Down Expand Up @@ -137,51 +132,31 @@ public void withdraw(Long userId) {
userRepository.delete(user);
}

private static final long TEMP_CODE_EXPIRATION_SECONDS = 120;
private final Map<String, TempExchangeData> tempCodeStore = new ConcurrentHashMap<>();

private record TempExchangeData(
Long userId,
SocialType socialType,
String socialId,
String profileImgUrl,
String deviceInfo,
boolean isNewUser,
LocalDateTime expiresAt
) {}

// ⚠️ 이 어노테이션을 지우면 클래스 레벨 @Transactional(readOnly=true)를 그대로 상속받아
// SocialAuth INSERT/UPDATE가 read-only 트랜잭션에서 실패한다 (#94 배포 서버 503 원인).
// AuthServiceTest는 클래스 전체가 @Transactional로 감싸져 있어 이 회귀를 못 잡으니 주의.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public AuthResponseDTO.LoginResponse exchangeTempCode(String tempCode) {
cleanExpiredTempCodes();
if (tempCode == null || tempCode.isBlank()) {
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}
TempExchangeData data = tempCodeStore.remove(tempCode.trim());
if (data == null || LocalDateTime.now().isAfter(data.expiresAt())) {
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}
OAuthTempCodeStore.TempExchangeData data = tempCodeStore.consume(tempCode)
.orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST));

try {
return authTransactionService.completeTokenExchange(
data.userId(),
data.socialType(),
data.socialId(),
data.profileImgUrl(),
data.deviceInfo(),
data.isNewUser()
data.deviceInfo()
);
} catch (DataIntegrityViolationException e) {
// socialLogin()/socialLoginByCode()와 달리 이 경로는 새 트랜잭션에서 재조회할
// 기존 계정 복구 로직이 없으므로, 곧바로 도메인 예외로 매핑한다.
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
return authTransactionService.executeSocialLoginForExistingUser(
data.socialType(),
new OAuthUserInfo(data.socialId(), data.profileImgUrl()),
data.deviceInfo()
);
}
}

private void cleanExpiredTempCodes() {
LocalDateTime now = LocalDateTime.now();
tempCodeStore.entrySet().removeIf(entry -> now.isAfter(entry.getValue().expiresAt()));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,33 @@ public class AuthTransactionService {
@Value("${app.profile.default-image-url}")
private String defaultProfileImageUrl;

public record SocialLoginPrepareResult(Long userId, String socialId, String profileImgUrl, boolean isNewUser) {}
public record SocialLoginPrepareResult(Long userId, String socialId, String profileImgUrl) {}

@Transactional(readOnly = true)
public SocialLoginPrepareResult prepareSocialLogin(SocialType socialType, OAuthUserInfo userInfo) {
Optional<SocialAuth> optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId());
if (optionalSocialAuth.isPresent()) {
User user = optionalSocialAuth.get().getUser();
return new SocialLoginPrepareResult(user.getUserId(), userInfo.socialId(), userInfo.profileImgUrl(), false);
return new SocialLoginPrepareResult(user.getUserId(), userInfo.socialId(), userInfo.profileImgUrl());
} else {
return new SocialLoginPrepareResult(null, userInfo.socialId(), userInfo.profileImgUrl(), true);
return new SocialLoginPrepareResult(null, userInfo.socialId(), userInfo.profileImgUrl());
}
}

public AuthResponseDTO.LoginResponse completeTokenExchange(Long userId, SocialType socialType, String socialId, String profileImgUrl, String deviceInfo, boolean isNewUser) {
public AuthResponseDTO.LoginResponse completeTokenExchange(Long userId, SocialType socialType, String socialId, String profileImgUrl, String deviceInfo) {
Optional<SocialAuth> optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, socialId);
User user;
if (userId != null) {
boolean actualNewUser;
if (optionalSocialAuth.isPresent()) {
user = optionalSocialAuth.get().getUser();
actualNewUser = false;
} else if (userId != null) {
user = userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND));
actualNewUser = false;
} else {
user = registerNewUserByProfileUrl(profileImgUrl);
actualNewUser = true;
}

String newAccessToken = tokenProvider.createAccessToken(user.getUserId());
Expand All @@ -63,7 +70,6 @@ public AuthResponseDTO.LoginResponse completeTokenExchange(Long userId, SocialTy
// 같은 트랜잭션 안에서 복구 쿼리를 다시 시도하면 그 쿼리도 실패한다.
// 여기서 잡아 도메인 예외로 변환하지 않고 그대로 전파해 트랜잭션을 롤백시키고,
// 호출 쪽(AuthService의 동시 로그인 재시도 로직)이 새 트랜잭션에서 복구를 시도하게 한다.
Optional<SocialAuth> optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, socialId);
if (optionalSocialAuth.isPresent()) {
SocialAuth socialAuth = optionalSocialAuth.get();
socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo);
Expand All @@ -88,15 +94,15 @@ public AuthResponseDTO.LoginResponse completeTokenExchange(Long userId, SocialTy
return AuthResponseDTO.LoginResponse.builder()
.userId(user.getUserId())
.nickname(user.getNickname())
.isNewUser(isNewUser)
.isNewUser(actualNewUser)
.isOnboardingCompleted(user.isOnboardingCompleted())
.tokenInfo(tokenResponse)
.build();
}

public AuthResponseDTO.LoginResponse executeSocialLogin(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) {
SocialLoginPrepareResult prepareResult = prepareSocialLogin(socialType, userInfo);
return completeTokenExchange(prepareResult.userId(), socialType, prepareResult.socialId(), prepareResult.profileImgUrl(), deviceInfo, prepareResult.isNewUser());
return completeTokenExchange(prepareResult.userId(), socialType, prepareResult.socialId(), prepareResult.profileImgUrl(), deviceInfo);
}

public AuthResponseDTO.LoginResponse executeSocialLoginForExistingUser(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) {
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/mr/domain/auth/service/OAuthClientService.java
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,14 @@ private OAuthUserInfo fetchGoogleUserInfo(String accessToken) {
.body(GoogleUserResponse.class);

if (fallbackResponse != null && fallbackResponse.id() != null && !fallbackResponse.id().isBlank() && !"null".equalsIgnoreCase(fallbackResponse.id().trim())) {
validateGoogleAudience(fallbackResponse.aud());
return OAuthUserInfo.builder()
.socialId(fallbackResponse.id())
.profileImgUrl(fallbackResponse.picture())
.build();
}
} catch (GeneralException generalException) {
throw generalException;
} catch (Exception fallbackEx) {
log.warn("Google tokeninfo fallback also failed: {}", fallbackEx.getMessage());
}
Expand All @@ -220,6 +223,17 @@ private OAuthUserInfo fetchGoogleUserInfo(String accessToken) {
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}

private void validateGoogleAudience(String audience) {
OAuthProperties.ProviderProperties googleProps = oAuthProperties != null ? oAuthProperties.google() : null;
String clientId = googleProps != null ? googleProps.clientId() : null;
if (clientId == null || clientId.isBlank()) {
throw new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR);
}
if (audience == null || !clientId.equals(audience)) {
throw new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR);
}
}

private boolean isJwtFormat(String token) {
if (token == null) return false;
int firstDot = token.indexOf('.');
Expand Down
Loading
Loading