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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ jobs:
service: howru-backend-service
cluster: howr-final-cluster
wait-for-service-stability: true
force-new-deployment: true # ✅ 강제 배포 옵션 추가
3 changes: 1 addition & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ dependencies {
// 🍃 NoSQL - MongoDB
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

// 🚀 Redis (동기식 Pub/Sub + 세션)
// 🚀 Redis (동기식 Pub/Sub + 캐싱)
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'

//🔐 Security + OAuth2
implementation 'org.springframework.boot:spring-boot-starter-security'
Expand Down
19 changes: 15 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,22 @@ services:
libretranslate:
image: libretranslate/libretranslate:latest
restart: unless-stopped
user: "0:0" # ← root 로 실행(볼륨 쓰기 권한 문제 해결)
ports:
- "5001:5000" # 호스트 5001 → 컨테이너 5000
- "5001:5000" # host 5001 -> container 5000
environment:
LT_LOAD_ONLY: "en,ko" # en/ko만 사용
LT_PRELOAD: "true" # 기동 시 en<->ko 모델 미리 로드
LT_UPDATE_MODELS: "true"
command: ["--host", "0.0.0.0", "--port", "5000"]
volumes:
- lt_models:/home/libretranslate/.local/share/argos-translate
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5001/ || exit 1"]
interval: 10s
test: ["CMD-SHELL", "curl -fsS http://localhost:5000/languages | grep -q '\"code\":\"ko\"' && grep -q '\"code\":\"en\"'"]
interval: 20s
timeout: 5s
retries: 3
retries: 10


spacy-api:
build:
Expand Down Expand Up @@ -110,6 +119,8 @@ services:
volumes:
pgdata:
redisdata:
lt_models:


networks:
kafka-net:
4 changes: 2 additions & 2 deletions infra/taskdef.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"family": "howru-backend",
"networkMode": "awsvpc",
"requiresCompatibilities": ["EC2"],
"cpu": "512",
"memory": "1024",
"cpu": "1024",
"memory": "4096",
"executionRoleArn": "arn:aws:iam::257976284139:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,23 @@ public ResponseEntity<TokenBundle> login(

@Operation(
summary = "토큰 갱신",
description = "membername과 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. " +
description = "리프레시 토큰과 만료된 액세스 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. " +
"리프레시 토큰은 쿠키에서 자동으로 읽어집니다."
)
@PostMapping("/refresh")
public ResponseEntity<TokenBundle> refresh(
@Parameter(description = "리프레시 토큰 (쿠키에서 자동 읽기)", hidden = true)
@CookieValue(value = "Refresh", required = false) String refreshToken,
@CookieValue(value = "Refresh", required = false) String refreshToken,
@Parameter(description = "만료된 액세스 토큰", required = true)
@RequestHeader("X-Expired-Access-Token") String expiredAccessToken,
HttpServletRequest request,
HttpServletResponse response
) {
if (refreshToken == null) {
throw new CustomException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND);
}

TokenBundle tokenBundle = authService.refreshToken(refreshToken);
TokenBundle tokenBundle = authService.refreshToken(refreshToken, expiredAccessToken);
response.setHeader("Authorization", "Bearer " + tokenBundle.access());

return ResponseEntity.ok(tokenBundle);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package org.example.howareyou.domain.auth.dto;

/** access / refresh / 프로필완료 플래그 */
/**
* 인증 토큰 번들
* @param access 액세스 토큰
* @param refresh 리프레시 토큰
* @param completed 프로필 완료 여부 (membername + 필수 프로필 정보 모두 완료)
*/
public record TokenBundle(
String access,
String refresh,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public class Auth extends BaseEntity {
private String providerUserId;

// 리프레시 토큰
@Column(name = "refresh_token", columnDefinition = "text")
private String refreshToken;

// 리프레시 토큰 만료 시간
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,25 @@ public void onAuthenticationSuccess(HttpServletRequest req,
boolean isSecure = !"dev".equals(activeProfile);
res.addCookie(CookieUtils.refresh(t.refresh(), isSecure));

// 3-2) 목적지 결정 (환경에 따라 다름)
// 3-2) 목적지 결정 (프로필 완료 상태에 따라)
String redirectUrl;
String path = t.completed() ? "/login/success" : "/signup/membername";
String path;

if (t.completed()) {
// 프로필 완료 → 로그인 성공 페이지
path = "/login/success";
} else {
// 프로필 미완료 → membername 설정 페이지
path = "/signup/membername";
}

redirectUrl = UriComponentsBuilder
.fromUriString(frontUrl)
.path(path)
.queryParam("oauth_success", "true")
.queryParam("provider", provider.name().toLowerCase())
.queryParam("profile_completed", String.valueOf(t.completed()))
.queryParam("access_token", t.access()) // Access Token 추가
.build().toUriString();

log.info("🔄 OAuth2 리다이렉트: {}", redirectUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,10 @@ public TokenBundle process(OAuth2User oAuth2User, HttpServletRequest request) {
// 2. 마지막 로그인 시간 갱신
auth.updateLastLoginInfo(UserAgentUtils.getClientIP(request));

// 3. 토큰 발급 (membername이 있으면 membername 기반, 없으면 email 기반)
String accessToken = jwtTokenProvider.createAccessToken(auth.getMember().getMembername());
String refreshToken;

if (auth.getMember().getMembername() != null && !auth.getMember().getMembername().isEmpty()) {
// membername이 있으면 membername 기반 Refresh Token
refreshToken = jwtTokenProvider.createRefreshTokenWithMembername(auth.getMember().getMembername());
} else {
// membername이 없으면 email 기반 Refresh Token
refreshToken = jwtTokenProvider.createRefreshTokenWithEmail(auth.getMember().getEmail());
}
// 3. 토큰 발급
// Access Token은 Auth ID 기반, Refresh Token은 Member ID 기반
String accessToken = jwtTokenProvider.createAccessToken(auth.getId());
String refreshToken = jwtTokenProvider.createRefreshToken(auth.getMember().getId());

Instant refreshTokenExpiry = Instant.now().plus(Duration.ofMillis(jwtTokenProvider.getRefreshTokenExpirationTime()));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,10 @@ public interface AuthRepository extends JpaRepository<Auth, Long> {
// 리프레시 토큰으로 인증 정보 조회
Optional<Auth> findByRefreshToken(String refreshToken);

/**
* Auth와 Member 정보를 함께 조회 (LazyInitializationException 방지)
*/
@Query("SELECT a FROM Auth a JOIN FETCH a.member WHERE a.id = :id")
Optional<Auth> findByIdWithMember(@Param("id") Long id);

}
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,9 @@ public TokenBundle login(String email, String password, HttpServletRequest reque
}

// 3. 토큰 생성 및 사용자 캐싱
String accessToken = jwtTokenProvider.createAccessToken(auth.getMember().getMembername());
String refreshToken;

if (auth.getMember().getMembername() != null && !auth.getMember().getMembername().isEmpty()) {
// membername이 있으면 membername 기반 Refresh Token
refreshToken = jwtTokenProvider.createRefreshTokenWithMembername(auth.getMember().getMembername());
} else {
// membername이 없으면 email 기반 Refresh Token
refreshToken = jwtTokenProvider.createRefreshTokenWithEmail(auth.getMember().getEmail());
}
// Access Token은 Auth ID 기반, Refresh Token은 Member ID 기반
String accessToken = jwtTokenProvider.createAccessToken(auth.getId());
String refreshToken = jwtTokenProvider.createRefreshToken(auth.getMember().getId());

// 4. 리프레시 토큰 저장
Instant refreshTokenExpiry = Instant.now()
Expand All @@ -83,17 +76,10 @@ public TokenBundle socialLogin(Provider provider, String email, String providerU
Auth auth = authRepository.findByProviderAndProviderUserId(provider, providerUserId)
.orElseThrow(() -> new CustomException(ErrorCode.AUTH_SOCIAL_ACCOUNT_NOT_FOUND));

// 2. 토큰 생성 (membername이 있으면 membername 기반, 없으면 email 기반)
String accessToken = jwtTokenProvider.createAccessToken(auth.getMember().getMembername());
String refreshToken;

if (auth.getMember().getMembername() != null && !auth.getMember().getMembername().isEmpty()) {
// membername이 있으면 membername 기반 Refresh Token
refreshToken = jwtTokenProvider.createRefreshTokenWithMembername(auth.getMember().getMembername());
} else {
// membername이 없으면 email 기반 Refresh Token
refreshToken = jwtTokenProvider.createRefreshTokenWithEmail(auth.getMember().getEmail());
}
// 2. 토큰 생성
// Access Token은 Auth ID 기반, Refresh Token은 Member ID 기반
String accessToken = jwtTokenProvider.createAccessToken(auth.getId());
String refreshToken = jwtTokenProvider.createRefreshToken(auth.getMember().getId());

// 3. 리프레시 토큰 갱신
Instant refreshTokenExpiry = Instant.now()
Expand Down Expand Up @@ -125,7 +111,7 @@ public void logout(Long memberId) {
* 액세스 토큰 재발급
*/
@Transactional
public TokenBundle refreshToken(String refreshToken) {
public TokenBundle refreshToken(String refreshToken, String expiredAccessToken) {
// 1. 리프레시 토큰으로 인증 정보 조회
Auth auth = authRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN));
Expand All @@ -135,35 +121,24 @@ public TokenBundle refreshToken(String refreshToken) {
throw new CustomException(ErrorCode.AUTH_EXPIRED_REFRESH_TOKEN);
}

// 3. 토큰에서 사용자 식별자 추출 및 검증
String tokenIdentifier = jwtTokenProvider.getIdentifierFromRefreshToken(refreshToken);
String tokenIdentifierType = jwtTokenProvider.getIdentifierTypeFromRefreshToken(refreshToken);

if (tokenIdentifier == null || tokenIdentifierType == null) {
throw new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN);
}

// 4. 식별자 타입에 따른 검증
Member member = auth.getMember();
boolean isValid = false;

if ("email".equals(tokenIdentifierType)) {
isValid = tokenIdentifier.equals(member.getEmail());
} else if ("membername".equals(tokenIdentifierType)) {
isValid = tokenIdentifier.equals(member.getMembername());
}

if (!isValid) {
log.warn("Refresh token identifier mismatch. Token: {}, DB: {}",
tokenIdentifier, "email".equals(tokenIdentifierType) ? member.getEmail() : member.getMembername());
// 3. 만료된 Access Token에서 Auth ID 추출 및 검증
try {
Long tokenAuthId = jwtTokenProvider.getAuthIdFromAccessToken(expiredAccessToken);
if (!tokenAuthId.equals(auth.getId())) {
log.warn("Access token auth ID mismatch. Token: {}, DB: {}",
tokenAuthId, auth.getId());
throw new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN);
}
} catch (Exception e) {
log.warn("Failed to extract auth ID from expired access token: {}", e.getMessage());
throw new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN);
}

// 5. 새로운 액세스 토큰 발급 (membername 사용)
String newAccessToken = jwtTokenProvider.createAccessToken(member.getMembername());
// 4. 새로운 액세스 토큰 발급 (Auth ID 기반)
String newAccessToken = jwtTokenProvider.createAccessToken(auth.getId());
log.info("Access token refreshed for auth ID: {}", auth.getId());

log.info("Access token refreshed for user: {}", member.getMembername());
return new TokenBundle(newAccessToken, refreshToken, member.isProfileCompleted());
return new TokenBundle(newAccessToken, refreshToken, auth.getMember().isProfileCompleted());
}

/**
Expand Down Expand Up @@ -198,7 +173,7 @@ public String refresh(String refreshToken) {
}

// 3. 새 AccessToken 발급
String userId = auth.getMember().getId().toString();
Long userId = auth.getMember().getId();
return jwtTokenProvider.createAccessToken(userId);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,15 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
try {
log.info("🔑 JWT 토큰 검증 시작");
// JWT 토큰 검증 및 사용자 정보 추출
String userId = jwtTokenProvider.validateAndGetSubject(token);
log.info("✅ JWT 토큰 검증 성공: userId={}", userId);
Long authId = jwtTokenProvider.getAuthIdFromAccessToken(token);
log.info("✅ JWT 토큰 검증 성공: authId={}", authId);

// CustomMemberDetails 생성
CustomMemberDetails userDetails = (CustomMemberDetails) customMemberDetailsService.loadUserByUsername(userId);
CustomMemberDetails userDetails = (CustomMemberDetails) customMemberDetailsService.loadUserByUsername(authId.toString());
Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
accessor.setUser(auth);

log.info("✅ WebSocket CONNECT 인증 성공: userId={}, membername={}", userId, userDetails.getMembername());
log.info("✅ WebSocket CONNECT 인증 성공: authId={}, membername={}", authId, userDetails.getMembername());
} catch (Exception e) {
log.error("❌ WebSocket CONNECT 인증 실패: {}", e.getMessage(), e);
// 인증 실패 시 연결 거부
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import org.example.howareyou.domain.chat.entity.ChatRoom;
import org.example.howareyou.domain.chat.repository.ChatRoomRepository;
import org.example.howareyou.domain.chat.repository.ChatRoomMemberRepository;
import org.example.howareyou.domain.chat.websocket.dto.ChatEnterDTO;
import org.example.howareyou.domain.chat.websocket.dto.ChatMessageResponse;
import org.example.howareyou.domain.chat.websocket.dto.CreateChatMessageRequest;
Expand All @@ -28,17 +29,20 @@
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.transaction.annotation.Transactional;

@RestController
@RequiredArgsConstructor
@Slf4j
@Transactional
@Tag(name = "WebSocket 채팅", description = "STOMP 기반 실시간 채팅 메시지 처리")
public class ChatController {

private final SimpMessagingTemplate messagingTemplate;
private final ChatMessageService chatMessageService;
private final ChatRedisService chatRedisService;
private final ChatRoomRepository chatRoomRepository;
private final ChatRoomMemberRepository chatRoomMemberRepository;
private final ChatMemberTracker chatMemberTracker;

/**
Expand Down Expand Up @@ -140,8 +144,8 @@ public void enterRoom(

String chatRoomId = dto.getChatRoomId();

// 입장 제한 로직
ChatRoom chatRoom = chatRoomRepository.findByUuid(chatRoomId)
// 입장 제한 로직 - members를 eager fetch로 로드
ChatRoom chatRoom = chatRoomMemberRepository.findByUuidWithMembers(chatRoomId)
.orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_NOT_FOUND));

Long memberId = Long.parseLong(userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@ public boolean isProfileCompleted() {
return this.profile != null && this.profile.isCompleted();
}

/** membername 설정 여부 */
public boolean hasMembername() {
return this.membername != null && !this.membername.trim().isEmpty() && !this.membername.startsWith("temp_");
}

/** 프로필 설정 가능 여부 (membername이 설정되어야 프로필 설정 가능) */
public boolean canSetupProfile() {
return hasMembername();
}

/** 계정 삭제(soft-delete) */
public void deleteAccount() {
this.active = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
@Entity
@Table(name = "member_profiles")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
Expand Down
Loading
Loading