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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.ject.studytrip.auth.application.dto;

public record OAuthLoginOutcome(Outcome outcome, TokenInfo tokenInfo, String signupKey) {
enum Outcome {
SUCCESS,
SIGNUP_REQUIRED
}

public static OAuthLoginOutcome success(
String accessToken, String refreshToken, long refreshTokenExpiresIn) {
return new OAuthLoginOutcome(
Outcome.SUCCESS,
new TokenInfo(accessToken, refreshToken, refreshTokenExpiresIn),
null);
}

public static OAuthLoginOutcome signupRequired(String signupKey) {
return new OAuthLoginOutcome(Outcome.SIGNUP_REQUIRED, null, signupKey);
}

public boolean isSignupRequired() {
return this.outcome == Outcome.SIGNUP_REQUIRED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ject.studytrip.auth.application.dto;

public record TokenInfo(String accessToken, String refreshToken, long refreshTokenExpiresIn) {
public static TokenInfo of(
String accessToken, String refreshToken, long refreshTokenExpiresIn) {
return new TokenInfo(accessToken, refreshToken, refreshTokenExpiresIn);
Copy link
Contributor

Choose a reason for hiding this comment

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

refreshTokenExpiresIn(리프레시토큰 만료시간)을 DTO에 같이 담아서 전달하고 있네요. 같이 전달한 이유가 궁금합니다.

Copy link
Contributor Author

@hisonghy hisonghy Sep 16, 2025

Choose a reason for hiding this comment

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

쿠키 관리는 HTTP와 관련된 로직이므로 Presentation 계층에서 전담하도록 설정해두었습니다.
리프레시 토큰과 쿠키의 만료시간을 동일하게 관리하기 위해 Application 계층의 TokenInfo DTO에서 발급된 리프레시 토큰의 만료시간을 함께 반환하고,
Presentation 계층에서 리프레시 쿠키를 생성할 때 토큰의 실제 만료시간을 기준으로 쿠키의 생명주기를 정확하게 관리하고자 refreshTokenExpiresIn(리프레시토큰 만료시간)을 함께 반환하도록 구성했습니다.

}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.ject.studytrip.auth.application.facade;

import com.ject.studytrip.auth.application.dto.OAuthLoginOutcome;
import com.ject.studytrip.auth.application.dto.TokenInfo;
import com.ject.studytrip.auth.application.service.KakaoLoginService;
import com.ject.studytrip.auth.application.service.KakaoSignupProfileService;
import com.ject.studytrip.auth.application.service.TokenService;
import com.ject.studytrip.auth.domain.model.KakaoSignupProfile;
import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse;
import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest;
import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest;
import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest;
import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest;
import com.ject.studytrip.auth.presentation.dto.response.TokenResponse;
import com.ject.studytrip.member.application.dto.CreateMemberCommand;
import com.ject.studytrip.member.application.service.MemberService;
import com.ject.studytrip.member.domain.model.Member;
Expand All @@ -19,42 +21,65 @@
@RequiredArgsConstructor
public class AuthFacade {
private final KakaoLoginService kakaoLoginService;
private final KakaoSignupProfileService kakaoSignupProfileService;
private final TokenService tokenService;
private final MemberService memberService;

public TokenResponse kakaoLogin(KakaoLoginRequest request, String origin) {
KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code(), origin);
public OAuthLoginOutcome kakaoLogin(KakaoLoginRequest request, String origin) {
KakaoUserInfoResponse info = kakaoLoginService.getKakaoUserInfo(request.code(), origin);

Member member =
memberService.getMemberBySocialProviderAndSocialId(
SocialProvider.KAKAO, response.kakaoId());

return tokenService.getTokens(member.getId().toString(), member.getRole().name());
return memberService
.getMemberBySocialProviderAndSocialId(SocialProvider.KAKAO, info.kakaoId())
// 가입되어 있는 사용자인 경우 토큰 발급
.map(
member ->
createLoginOutcomeWithIssuedTokens(
member.getId(), member.getRole().name()))
// 가입이 필요할 경우 가입 키 발급
.orElseGet(() -> createSignupRequiredOutcomeWithIssuedSignupKey(info));
}

public TokenResponse kakaoSignup(KakaoSignupRequest request, String origin) {
KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code(), origin);
public TokenInfo kakaoSignup(String signupKey, KakaoSignupRequest request) {
KakaoSignupProfile profile = kakaoSignupProfileService.getSignupProfileByKey(signupKey);
CreateMemberCommand command =
CreateMemberCommand.of(
response.kakaoId(),
response.getEmail(),
response.getProfileImage(),
profile.socialId(),
profile.email(),
profile.profileImageUrl(),
request.nickname(),
request.category());

Member member = memberService.createMemberFromKakao(command);

kakaoSignupProfileService.deleteBySignupKey(signupKey);

return tokenService.getTokens(member.getId().toString(), member.getRole().name());
}

public TokenResponse reissueToken(TokenReissueRequest request) {
String memberId = tokenService.getMemberIdByRefreshToken(request.refreshToken());
public TokenInfo reissueToken(String refreshToken) {
String memberId = tokenService.getMemberIdByRefreshToken(refreshToken);
String role = memberService.getRoleByMemberId(memberId);

return tokenService.reissueToken(request.refreshToken(), memberId, role);
return tokenService.reissueToken(refreshToken, memberId, role);
}

public void logout(LogoutRequest request, String refreshToken) {
tokenService.logout(request.accessToken(), refreshToken);
}

private OAuthLoginOutcome createLoginOutcomeWithIssuedTokens(Long memberId, String roleName) {
// 토큰 발급
TokenInfo tokens = tokenService.getTokens(memberId.toString(), roleName);
return OAuthLoginOutcome.success(
tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenExpiresIn());
}

public void logout(LogoutRequest request) {
tokenService.logout(request.accessToken(), request.refreshToken());
private OAuthLoginOutcome createSignupRequiredOutcomeWithIssuedSignupKey(
KakaoUserInfoResponse info) {
// 카카오 가입 프로필 임시 저장 및 키 발급
String signupKey =
kakaoSignupProfileService.saveAndIssueSignupKey(
info.kakaoId(), info.getEmail(), info.getProfileImage());
return OAuthLoginOutcome.signupRequired(signupKey);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.ject.studytrip.auth.application.service;

import com.ject.studytrip.auth.domain.error.AuthErrorCode;
import com.ject.studytrip.auth.domain.model.KakaoSignupProfile;
import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository;
import com.ject.studytrip.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class KakaoSignupProfileService {
private final KakaoSignupProfileRedisRepository kakaoSignupProfileRedisRepository;

public String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl) {
return kakaoSignupProfileRedisRepository.saveAndIssueSignupKey(
socialId, email, profileImageUrl);
}

public KakaoSignupProfile getSignupProfileByKey(String signupKey) {
if (signupKey == null || signupKey.isBlank()) {
throw new CustomException(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY);
}

return kakaoSignupProfileRedisRepository
.findBySignupKey(signupKey)
.orElseThrow(() -> new CustomException(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY));
}

public void deleteBySignupKey(String signupKey) {
kakaoSignupProfileRedisRepository.deleteBySignupKey(signupKey);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.ject.studytrip.auth.application.service;

import com.ject.studytrip.auth.application.dto.TokenInfo;
import com.ject.studytrip.auth.domain.error.AuthErrorCode;
import com.ject.studytrip.auth.domain.repository.LogoutTokenRedisRepository;
import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository;
import com.ject.studytrip.auth.infra.provider.TokenProvider;
import com.ject.studytrip.auth.presentation.dto.response.TokenResponse;
import com.ject.studytrip.global.exception.CustomException;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand All @@ -20,18 +20,18 @@ public class TokenService {
private final LogoutTokenRedisRepository logoutTokenRedisRepository;
private final RefreshTokenRedisRepository refreshTokenRedisRepository;

public TokenResponse getTokens(String memberId, String role) {
public TokenInfo getTokens(String memberId, String role) {
String accessToken = tokenProvider.createAccessToken(memberId, role);
String refreshToken = tokenProvider.createRefreshToken();
long refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime();

refreshTokenRedisRepository.saveRefreshToken(
memberId, refreshToken, refreshTokenExpirationTime);

return TokenResponse.of(accessToken, refreshToken);
return TokenInfo.of(accessToken, refreshToken, refreshTokenExpirationTime);
}

public TokenResponse reissueToken(String refreshToken, String memberId, String role) {
public TokenInfo reissueToken(String refreshToken, String memberId, String role) {
long refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime();
String newAccessToken = tokenProvider.createAccessToken(memberId, role);
String newRefreshToken = tokenProvider.createRefreshToken();
Expand All @@ -40,7 +40,7 @@ public TokenResponse reissueToken(String refreshToken, String memberId, String r
refreshTokenRedisRepository.saveRefreshToken(
memberId, newRefreshToken, refreshTokenExpirationTime);

return TokenResponse.of(newAccessToken, newRefreshToken);
return TokenInfo.of(newAccessToken, newRefreshToken, refreshTokenExpirationTime);
}

public void logout(String accessToken, String refreshToken) {
Expand Down Expand Up @@ -77,6 +77,10 @@ public void validateActiveAccessToken(String accessToken) {
}

private void validateRefreshToken(String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) {
throw new CustomException(AuthErrorCode.MISSING_REFRESH_TOKEN);
}

if (!refreshTokenRedisRepository.existsRefreshToken(refreshToken)) {
throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ public enum AuthErrorCode implements ErrorCode {
INVALID_KAKAO_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 카카오 액세스 토큰입니다."),
INVALID_KAKAO_AUTHORIZATION_CODE(HttpStatus.BAD_REQUEST, "잘못된 카카오 인가 코드입니다."),
KAKAO_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "카카오 서버에서 오류가 발생했습니다."),
MISSING_KAKAO_SIGNUP_KEY(HttpStatus.BAD_REQUEST, "카카오 가입 키(signupKey)가 누락되었습니다."),
INVALID_KAKAO_SIGNUP_KEY(
HttpStatus.BAD_REQUEST, "요청한 카카오 가입 키(signupKey)의 정보가 존재하지 않거나 만료되었습니다."),

// 인증 관련 예외
INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 JWT 토큰입니다."),
MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "리프레시 토큰이 누락되었습니다."),
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 리프레시 토큰입니다."),
TOKEN_IS_BLACKLISTED(HttpStatus.UNAUTHORIZED, "블랙리스트된 엑세스 토큰입니다."),
;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ject.studytrip.auth.domain.model;

public record KakaoSignupProfile(
String socialId, String socialProvider, String email, String profileImageUrl) {
public static KakaoSignupProfile of(
String socialId, String socialProvider, String email, String profileImageUrl) {
return new KakaoSignupProfile(socialId, socialProvider, email, profileImageUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ject.studytrip.auth.domain.repository;

import com.ject.studytrip.auth.domain.model.KakaoSignupProfile;
import java.util.Optional;

public interface KakaoSignupProfileRedisRepository {
String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl);

Optional<KakaoSignupProfile> findBySignupKey(String signupKey);

void deleteBySignupKey(String signupKey);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.ject.studytrip.auth.infra.repository.redis;

import static com.ject.studytrip.global.common.constants.CacheKeyConstants.*;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ject.studytrip.auth.domain.model.KakaoSignupProfile;
import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository;
import com.ject.studytrip.member.domain.model.SocialProvider;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class KakaoSignupProfileRedisRepositoryAdapter implements KakaoSignupProfileRedisRepository {
private static final long KAKAO_SIGNUP_PROFILE_TTL_MILLIS = 900000;

private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;

@Override
public String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl) {
String socialProvider = SocialProvider.KAKAO.name().toLowerCase();
String key = issueKey(socialProvider);
KakaoSignupProfile signupProfile =
KakaoSignupProfile.of(socialId, socialProvider, email, profileImageUrl);

redisTemplate
.opsForValue()
.set(key, signupProfile, KAKAO_SIGNUP_PROFILE_TTL_MILLIS, TimeUnit.MILLISECONDS);

return key;
}

@Override
public Optional<KakaoSignupProfile> findBySignupKey(String signupKey) {
return Optional.ofNullable(redisTemplate.opsForValue().get(signupKey))
.map(value -> objectMapper.convertValue(value, KakaoSignupProfile.class));
}

@Override
public void deleteBySignupKey(String signupKey) {
redisTemplate.delete(signupKey);
}

private String issueKey(String socialProvider) {
return OAUTH_SIGNUP_PROFILE_PREFIX.formatted(socialProvider) + UUID.randomUUID();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,11 @@ public class LogoutTokenRedisRepositoryAdapter implements LogoutTokenRedisReposi
public void saveAccessToken(String accessToken, long accessTokenExpirationTime) {
redisTemplate
.opsForValue()
.set(
AUTH_LOGOUT_TOKEN_PREFIX.getValue() + accessToken,
"LOGOUT",
accessTokenExpirationTime);
.set(AUTH_LOGOUT_TOKEN_PREFIX + accessToken, "LOGOUT", accessTokenExpirationTime);
}

@Override
public boolean existsAccessToken(String accessToken) {
return Boolean.TRUE.equals(
redisTemplate.hasKey(AUTH_LOGOUT_TOKEN_PREFIX.getValue() + accessToken));
return Boolean.TRUE.equals(redisTemplate.hasKey(AUTH_LOGOUT_TOKEN_PREFIX + accessToken));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,24 @@ public void saveRefreshToken(
redisTemplate
.opsForValue()
.set(
AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken,
AUTH_REISSUE_TOKEN_PREFIX + refreshToken,
memberId,
refreshTokenExpireTime,
TimeUnit.MILLISECONDS);
}

@Override
public boolean existsRefreshToken(String refreshToken) {
return Boolean.TRUE.equals(
redisTemplate.hasKey(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken));
return Boolean.TRUE.equals(redisTemplate.hasKey(AUTH_REISSUE_TOKEN_PREFIX + refreshToken));
}

@Override
public void deleteRefreshToken(String refreshToken) {
redisTemplate.delete(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken);
redisTemplate.delete(AUTH_REISSUE_TOKEN_PREFIX + refreshToken);
}

@Override
public String findMemberIdByRefreshToken(String refreshToken) {
return redisTemplate.opsForValue().get(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken);
return redisTemplate.opsForValue().get(AUTH_REISSUE_TOKEN_PREFIX + refreshToken);
}
}
Loading