diff --git a/docs/pr/PR-139-refactor---auth-api.md b/docs/pr/PR-139-refactor---auth.md similarity index 100% rename from docs/pr/PR-139-refactor---auth-api.md rename to docs/pr/PR-139-refactor---auth.md diff --git a/docs/pr/PR-140-refactor--public-access-control.md.md b/docs/pr/PR-140-refactor---public-access-control.md.md similarity index 100% rename from docs/pr/PR-140-refactor--public-access-control.md.md rename to docs/pr/PR-140-refactor---public-access-control.md.md diff --git a/docs/pr/PR-141-refactor---auth.md b/docs/pr/PR-141-refactor---auth.md new file mode 100644 index 00000000..09a6de5a --- /dev/null +++ b/docs/pr/PR-141-refactor---auth.md @@ -0,0 +1,131 @@ +# 치명적인 보안 패치 및 인증/인가 리팩토링 (PR [#141](https://github.com/juulabel/juulabel-back/pull/143)) + +## TL;DR + +이번 PR은 소셜 로그인 프로세스에 존재했던 **치명적인 보안 취약점**을 해결하고, 불필요한 데이터베이스 호출을 줄이며 도메인 책임을 명확히 했습니다. + +| 항목 | Before | After | 결과 | +| :------------------- | :------------------------ | :--------------------- | :------------------------ | +| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** | +| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** | +| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** | +| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** | + +--- + +## 💥 핵심 문제 해결 + +### 1. 소셜 로그인 우회 취약점 차단 + +**문제점:** 이전에는 사용자가 소셜 인증 핸드셰이크 과정을 완전히 우회할 수 있었습니다. `/v1/api/auth/sign-up` 엔드포인트에 조작된 데이터를 직접 호출함으로써, 실제 검증 없이 계정을 생성할 수 있었습니다. + +```javascript +// 이전 취약점: 검증되지 않은 회원가입 허용 +POST /v1/api/auth/sign-up +{ + "email": "compromised@email.com", + "nickname": "fake_name", + "provider": "GOOGLE", + "providerId": "fake_google_id", + ... +} +``` + +**해결**: TTL 기반 소셜 인증 상태 관리 + +- 소셜 로그인 시 로그인 정보와 request header로부터 받아올수있는 metatdata를 Redis에 30분 TTL로 저장 +- 회원가입 시 로그인떄와 저장된 정보와 100% 일치하는지 검증 +- 불일치하거나 TTL 만료 시 가입 차단 + +### 2. 인증 로직 보완 + +**Before**: 기존에는 이미 가입된 이메일의 다른 소셜 로그인을 통한 처리를 회원가입부분에서 검증. + +```java +boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); +``` + +**After**: 로그인 과정에서도 검증하여 에러 반환 + +```java +public void validateLoginMember(Provider provider, String providerId) { + if (this.deletedAt != null) { + throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); + } + // 사용자가 등록한 *동일한* 제공업체를 통해 로그인하는지 확인 + if (!this.provider.equals(provider)) { + throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + // 중요: *올바른* 제공업체의 고유 ID 확인 + if (!this.providerId.equals(providerId)) { + throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); + } +} +``` + +## 3. 데이터베이스 성능 최적화 + +**Before**: 회원가입 과정에서는 INSERT를 시도하기 전에 닉네임 충돌, 이메일 충돌, 탈퇴 상태를 확인하기 위해 여러 번의 중복된 EXISTS 쿼리가 발생했습니다. 이로 인해 불필요한 데이터베이스 왕복이 발생했습니다. + +```java +// 비효율적: 삽입 전 여러 사전 확인 +boolean nicknameExists = memberRepository.existsByNickname(nickname); // 쿼리 1 +boolean emailExists = memberRepository.existsByEmail(email); // 쿼리 2 +boolean isWithdrawn = withdrawalRepository.existsByEmail(email); // 쿼리 3 +memberRepository.save(member); // 쿼리 4 +``` + +**After**: 데이터베이스의 내장된 제약 조건 위반 처리를 활용합니다. INSERT를 직접 시도하고 DataIntegrityViolationException (예: 고유 키 충돌)과 같은 잠재적인 예외 사례를 우아하게 처리합니다. 이 접근 방식은 일반적인 회원가입 흐름을 단일 INSERT 쿼리로 줄입니다. + +```java +// 효율적: 단일 쿼리, DB 제약 조건 활용 (낙관적) +try { + memberRepository.save(member); // 쿼리 1 +} catch (DataIntegrityViolationException e) { + // 특정 제약 조건 위반 처리 (예: 이메일/닉네임 중복) + handleConstraintViolation(e); +} +``` + +## 기타 사항 + +### 불필요한 DTO 제거 + +```java +// 제거된 중간 변환 객체 +public class OAuthLoginInfo { /* 불필요한 래핑 */ } + +// 불필요한 변환 발생 +public record OAuthLoginRequest( + /* 불필요한 래핑 */ +) { + public OAuthLoginInfo toDto() { + Map propertyMap = Map.of( + AuthConstants.CODE, code, + AuthConstants.REDIRECT_URI, redirectUri + ); + return new OAuthLoginInfo(provider, propertyMap); + } +} + +// 객체 변환 간소화 +final OAuthUser oAuthUser = providerFactory.getOAuthUser(oAuthLoginRequest); +``` + +### AuthExcpetion 추가 + +기존에는 인증/인가 관련 예외(Authentication, Authorization)를 포함해 모든 예외를 동일한 수준에서 처리하고 있었습니다. 이 방식은 간단하지만 다음과 같은 단점이 있습니다: + +❗ 문제점 +• 문제 추적이 어렵다: 인증 관련 문제인지, 비즈니스 로직 문제인지 구분되지 않음 +• 모니터링/알림 설정이 어려움: 특정 보안 이슈에 대한 빠른 탐지가 불가능 +• 책임 경계 불분명: 도메인 계층과 인증 계층의 에러가 동일하게 처리됨 + +⸻ + +🎯 개선 방향: AuthException 정의 및 분리 +• 인증/인가 실패 상황(providerId 불일치, 로그인되지 않은 사용자, 토큰 유효성 문제 등)에 대해 별도 예외 클래스를 정의 +• BaseException으로부터 상속, ErrorCode를 명확히 지정 +• 차후 로그 필터링/슬랙 알림/보안 모니터링 등에서 인증 이슈만 별도로 추적 가능 + + diff --git a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java new file mode 100644 index 00000000..4ef6de6b --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java @@ -0,0 +1,107 @@ +package com.juu.juulabel.auth.domain; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import static com.juu.juulabel.common.constants.AuthConstants.SOCIAL_LINK_DURATION; +import static com.juu.juulabel.common.constants.AuthConstants.SOCIAL_LINK_PREFIX; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Provider; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.AccessLevel; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RedisHash(SOCIAL_LINK_PREFIX) +public class SocialLink implements Serializable { + + @Id + private String hashedEmail; + + private Provider provider; + + private String providerId; + + private String deviceId; + + private String ipAddress; + + private String userAgent; + + private Long usedAt; + + @TimeToLive(unit = TimeUnit.SECONDS) + private Long ttl; + + @Builder + public SocialLink(String hashedEmail, Provider provider, String providerId, String deviceId, String userAgent, + String ipAddress) { + this.hashedEmail = hashedEmail; + this.provider = provider; + this.providerId = providerId; + this.deviceId = deviceId; + this.userAgent = userAgent; + this.ipAddress = ipAddress; + this.usedAt = null; + this.ttl = SOCIAL_LINK_DURATION.getSeconds(); + } + + /** + * Validates the social link against provided parameters for security purposes. + * Throws AuthException if validation fails. + */ + public void validate(Provider provider, String providerId, String deviceId, String userAgent) { + // Check if already used + if (isAlreadyUsed()) { + throw new AuthException(ErrorCode.SOCIAL_LINK_ALREADY_USED); + } + + // Validate parameters match stored values + if (!isValidationParametersMatch(provider, providerId, deviceId, userAgent)) { + throw new AuthException("Validation failed due to parameter mismatch"); + } + } + + /** + * Marks this social link as used with current timestamp. + * Can only be used once. + */ + public void markAsUsed() { + if (isAlreadyUsed()) { + throw new AuthException(ErrorCode.SOCIAL_LINK_ALREADY_USED); + } + this.usedAt = Instant.now().getEpochSecond(); + } + + /** + * Checks if this social link has already been used. + */ + public boolean isAlreadyUsed() { + return this.usedAt != null; + } + + /** + * Checks if validation parameters match stored values. + * Uses efficient short-circuit evaluation. + */ + private boolean isValidationParametersMatch(Provider provider, String providerId, String deviceId, + String userAgent) { + return Objects.equals(this.provider, provider) && + Objects.equals(this.providerId, providerId) && + Objects.equals(this.deviceId, deviceId) && + Objects.equals(this.userAgent, userAgent); + } +} diff --git a/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java index 25c43b79..4ea283c4 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java +++ b/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component; import com.juu.juulabel.auth.domain.RefreshToken; +import com.juu.juulabel.redis.RedisScriptExecutor; @Component public class LoginRefreshTokenScriptExecutor implements RedisScriptExecutor { diff --git a/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java index f7ef25ed..77480de7 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java +++ b/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java @@ -11,6 +11,8 @@ import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; +import com.juu.juulabel.redis.RedisScriptExecutor; + @Component public class RevokeRefreshTokenByIndexKeyExecutor implements RedisScriptExecutor { diff --git a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java index 0c83b421..e38f1185 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java +++ b/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java @@ -15,8 +15,10 @@ import com.juu.juulabel.auth.domain.RefreshToken; import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.AuthException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.util.HttpResponseUtil; +import com.juu.juulabel.redis.RedisScriptExecutor; @Component public class RotateRefreshTokenScriptExecutor implements RedisScriptExecutor { @@ -52,11 +54,11 @@ public Object execute(RefreshToken refreshToken, Object... args) { public void handleRedisScriptError(String errorMessage) { HttpResponseUtil.addCookie(AuthConstants.REFRESH_TOKEN_HEADER_NAME, "", 0); if (errorMessage.contains("OLD_TOKEN_NOT_FOUND")) { - throw new BaseException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + throw new AuthException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); } else if (errorMessage.contains("OLD_TOKEN_ALREADY_REVOKED_ALL_TOKENS_INVALIDATED")) { - throw new BaseException(ErrorCode.REFRESH_TOKEN_REUSE_DETECTED); + throw new AuthException(ErrorCode.REFRESH_TOKEN_REUSE_DETECTED); } else if (errorMessage.contains("DEVICE_ID_MISMATCH")) { - throw new BaseException(ErrorCode.DEVICE_ID_MISMATCH); + throw new AuthException(ErrorCode.DEVICE_ID_MISMATCH); } else { throw new BaseException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java index 0ed0e2e2..46fccad2 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java +++ b/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Component; import com.juu.juulabel.auth.domain.RefreshToken; +import com.juu.juulabel.redis.RedisScriptExecutor; @Component public class SaveRefreshTokenScriptExecutor implements RedisScriptExecutor { diff --git a/src/main/java/com/juu/juulabel/auth/repository/RedisRefreshTokenRepository.java b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java similarity index 89% rename from src/main/java/com/juu/juulabel/auth/repository/RedisRefreshTokenRepository.java rename to src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java index 6e55cbc5..9da8ac87 100644 --- a/src/main/java/com/juu/juulabel/auth/repository/RedisRefreshTokenRepository.java +++ b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java @@ -2,15 +2,16 @@ import com.juu.juulabel.auth.domain.ClientId; import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.auth.executor.RedisScriptName; -import com.juu.juulabel.auth.executor.ScriptRegistry; import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.redis.RedisScriptName; +import com.juu.juulabel.redis.ScriptRegistry; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor -public class RedisRefreshTokenRepository implements RefreshTokenRepository { +public class RefreshTokenRepositoryImpl implements RefreshTokenRepository { private final ScriptRegistry scriptRegistry; diff --git a/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java b/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java new file mode 100644 index 00000000..7f51aab3 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java @@ -0,0 +1,9 @@ +package com.juu.juulabel.auth.repository; + +import org.springframework.data.repository.CrudRepository; + +import com.juu.juulabel.auth.domain.SocialLink; + +public interface SocialLinkRepository extends CrudRepository { + +} diff --git a/src/main/java/com/juu/juulabel/auth/service/AuthService.java b/src/main/java/com/juu/juulabel/auth/service/AuthService.java index e230fb21..e6c1bfa7 100644 --- a/src/main/java/com/juu/juulabel/auth/service/AuthService.java +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -5,21 +5,16 @@ import com.juu.juulabel.common.dto.response.LoginResponse; import com.juu.juulabel.common.dto.response.RefreshResponse; import com.juu.juulabel.common.dto.response.SignUpMemberResponse; -import com.juu.juulabel.common.exception.InvalidParamException; -import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.factory.OAuthProviderFactory; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.WithdrawalRecord; import com.juu.juulabel.member.repository.MemberReader; import com.juu.juulabel.member.repository.MemberWriter; -import com.juu.juulabel.member.repository.WithdrawalRecordReader; import com.juu.juulabel.member.repository.WithdrawalRecordWriter; -import com.juu.juulabel.member.request.OAuthLoginInfo; import com.juu.juulabel.member.token.Token; import com.juu.juulabel.member.util.MemberUtils; import lombok.RequiredArgsConstructor; import com.juu.juulabel.member.domain.Provider; -import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.request.OAuthUserInfo; import com.juu.juulabel.common.dto.request.OAuthLoginRequest; @@ -37,60 +32,65 @@ public class AuthService { private final WithdrawalRecordWriter withdrawalRecordWriter; private final MemberUtils memberUtils; private final OAuthProviderFactory providerFactory; - private final WithdrawalRecordReader withdrawalRecordReader; private final TokenService tokenService; + private final SocialLinkService socialLinkService; @Transactional public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { - OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); - Provider provider = authLoginInfo.provider(); - - String accessToken = providerFactory.getAccessToken( - provider, - authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), - authLoginInfo.propertyMap().get(AuthConstants.CODE)); - - OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, accessToken); - String email = oAuthUser.email(); - - validateNotWithdrawnMember(email); - - boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); - Optional memberOpt = isNewMember ? Optional.empty() : Optional.of(memberReader.getByEmail(email)); + // Extract OAuth information + final OAuthUser oAuthUser = providerFactory.getOAuthUser(oAuthLoginRequest); + + final Provider provider = oAuthLoginRequest.provider(); + final String providerId = oAuthUser.id(); + final String email = oAuthUser.email(); + + // Check if member exists + final Optional memberOpt = memberReader.getOptionalByEmail(email); + final boolean isNewMember = memberOpt.isEmpty(); + + if (isNewMember) { + socialLinkService.save(email, provider, providerId); + } else { + // For existing members, validate and create tokens + final Member member = memberOpt.get(); + member.validateLoginMember(provider, providerId); + + // Create refresh token for login (handles device management) + tokenService.createLoginRefreshToken(member); + } - Optional token = tokenService.createAccessToken(memberOpt); + Token accessToken = tokenService.createAccessToken(memberOpt) + .orElseGet(() -> new Token(null, null)); - // Create refresh token for existing members - memberOpt.ifPresent(member -> tokenService.createLoginRefreshToken(member)); + Long memberId = memberOpt.map(Member::getId).orElseGet(() -> null); return new LoginResponse( - token.orElse(new Token(null, null)), + accessToken, isNewMember, new OAuthUserInfo( - memberOpt.map(Member::getId).orElse(null), + memberId, email, - oAuthUser.id(), + providerId, provider)); } @Transactional public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { - validateSignUpRequest(signUpRequest); + socialLinkService.verify(signUpRequest.email(), signUpRequest.provider(), signUpRequest.providerId()); - Member member = Member.create(signUpRequest); + final Member member = Member.create(signUpRequest); memberWriter.store(member); - memberUtils.processAlcoholTypes(member, signUpRequest); - memberUtils.processTermsAgreements(member, signUpRequest); + memberUtils.processMemberData(member, signUpRequest); - Token token = tokenService.createTokenPair(member); + // Create token pair for new member + final Token token = tokenService.createTokenPair(member); return new SignUpMemberResponse(member.getId(), token); } - @Transactional public RefreshResponse refresh(String oldToken) { - Token newToken = tokenService.rotateRefreshToken(oldToken); + final Token newToken = tokenService.rotateRefreshToken(oldToken); return new RefreshResponse(newToken.accessToken()); } @@ -100,26 +100,17 @@ public void logout(String oldToken) { @Transactional public void deleteAccount(Member loginMember, WithdrawalRequest request, String oldToken) { + // Mark member as deleted loginMember.deleteAccount(); - withdrawalRecordWriter.store( - WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname())); - tokenService.revokeAllRefreshTokens(oldToken); - } + // Create withdrawal record + final WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( + request.withdrawalReason(), + loginMember.getEmail(), + loginMember.getNickname()); + withdrawalRecordWriter.store(withdrawalRecord); - private void validateNotWithdrawnMember(String email) { - if (withdrawalRecordReader.existEmail(email)) { - throw new InvalidParamException(ErrorCode.MEMBER_WITHDRAWN); - } - } - - private void validateSignUpRequest(SignUpMemberRequest signUpRequest) { - if (memberReader.existActiveNickname(signUpRequest.nickname())) { - throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); - } - - if (memberReader.existActiveEmail(signUpRequest.email())) { - throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); - } + // Revoke all tokens + tokenService.revokeAllRefreshTokens(oldToken); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java b/src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java deleted file mode 100644 index 83ea0535..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.juu.juulabel.auth.service; - -public interface FraudDetectionService { - RiskAssessment assessRisk(T data, - String currentIpAddress, String currentUserAgent, String currentDeviceId); - -} diff --git a/src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java b/src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java deleted file mode 100644 index 41d7188a..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.juu.juulabel.auth.service; - -import lombok.Getter; - -/** - * Risk assessment result - */ -@Getter -public class RiskAssessment { - private final double score; // 0.0 (low) to 1.0 (high) - private final String reason; - private final boolean familyShouldBeCompromised; - - public RiskAssessment(double score, String reason, boolean familyShouldBeCompromised) { - this.score = score; - this.reason = reason; - this.familyShouldBeCompromised = familyShouldBeCompromised; - } - - public boolean isHighRisk() { - /* e.g., score > 0.8 */ - return score > 0.8; - } - - public boolean isFamilyCompromised() { - return familyShouldBeCompromised; - } -} diff --git a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java b/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java new file mode 100644 index 00000000..fafc856f --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java @@ -0,0 +1,47 @@ +package com.juu.juulabel.auth.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import com.juu.juulabel.auth.domain.SocialLink; +import com.juu.juulabel.auth.repository.SocialLinkRepository; +import com.juu.juulabel.common.util.DeviceIdExtractor; +import com.juu.juulabel.common.util.HashingUtil; +import com.juu.juulabel.common.util.IpAddressExtractor; +import com.juu.juulabel.common.util.UserAgentExtractor; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +@Service +@RequiredArgsConstructor +public class SocialLinkService { + + private final SocialLinkRepository socialLinkRepository; + + public void save(String email, Provider provider, String providerId) { + SocialLink socialLink = SocialLink.builder() + .hashedEmail(HashingUtil.hashSha256(email)) + .provider(provider) + .providerId(providerId) + .deviceId(DeviceIdExtractor.getDeviceId()) + .userAgent(UserAgentExtractor.getUserAgent()) + .ipAddress(IpAddressExtractor.getClientIpAddress()) + .build(); + socialLinkRepository.save(socialLink); + } + + public void verify(String email, Provider provider, String providerId) { + String hashedEmail = HashingUtil.hashSha256(email); + + SocialLink socialLink = socialLinkRepository.findById(hashedEmail) + .orElseThrow(() -> new AuthException(ErrorCode.SOCIAL_LINK_NOT_FOUND)); + + socialLink.validate(provider, providerId, DeviceIdExtractor.getDeviceId(), + UserAgentExtractor.getUserAgent()); + + socialLink.markAsUsed(); + socialLinkRepository.save(socialLink); + } +} diff --git a/src/main/java/com/juu/juulabel/auth/service/TokenService.java b/src/main/java/com/juu/juulabel/auth/service/TokenService.java index 88c01451..c90088a3 100644 --- a/src/main/java/com/juu/juulabel/auth/service/TokenService.java +++ b/src/main/java/com/juu/juulabel/auth/service/TokenService.java @@ -6,6 +6,7 @@ import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.provider.JwtTokenProvider; import com.juu.juulabel.common.util.DeviceIdExtractor; +import com.juu.juulabel.common.util.HashingUtil; import com.juu.juulabel.common.util.IpAddressExtractor; import com.juu.juulabel.common.util.UserAgentExtractor; import com.juu.juulabel.common.util.HttpResponseUtil; @@ -68,7 +69,7 @@ public void createLoginRefreshToken(Member member) { @Transactional public Token rotateRefreshToken(String oldToken) { Member member = jwtTokenProvider.getMemberFromToken(oldToken); - String hashedOldToken = jwtTokenProvider.hashToken(oldToken); + String hashedOldToken = HashingUtil.hashSha256(oldToken); RefreshToken newRefreshToken = createRefreshToken(member); @@ -105,7 +106,7 @@ public void revokeAllRefreshTokens(String token) { private RefreshToken createRefreshToken(Member member) { String token = jwtTokenProvider.createRefreshToken(member); - String hashedToken = jwtTokenProvider.hashToken(token); + String hashedToken = HashingUtil.hashSha256(token); return RefreshToken.builder() .token(token) diff --git a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java index bda251ab..b92bddd6 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -8,16 +8,16 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class AuthConstants { - public static final String CODE = "code"; - public static final String REDIRECT_URI = "redirectUri"; - public static final String TOKEN_PREFIX = "Bearer "; - // The RFC 6648 (published in 2012) deprecated the X- prefix for custom headers: public static final String REFRESH_TOKEN_HEADER_NAME = "Refresh-Token"; - public static final String REFRESH_TOKEN_HASH_PREFIX = "RefreshToken"; - public static final String REFRESH_TOKEN_INDEX_PREFIX = "RefreshIndex"; public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1); public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(30); + public static final Duration SOCIAL_LINK_DURATION = Duration.ofMinutes(30); + + // Redis Prefix + public static final String SOCIAL_LINK_PREFIX = "social_link"; + public static final String REFRESH_TOKEN_HASH_PREFIX = "refresh_token"; + public static final String REFRESH_TOKEN_INDEX_PREFIX = "refresh_index"; } diff --git a/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java b/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java index 1db478e8..3a4358ed 100644 --- a/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java +++ b/src/main/java/com/juu/juulabel/common/dto/request/OAuthLoginRequest.java @@ -1,26 +1,12 @@ package com.juu.juulabel.common.dto.request; -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.member.request.OAuthLoginInfo; import com.juu.juulabel.member.domain.Provider; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import java.util.Map; - public record OAuthLoginRequest( - @NotBlank(message = "인가코드가 누락되었습니다.") - String code, - @NotNull(message = "리다이렉트 URI가 누락되었습니다.") - String redirectUri, - @NotNull(message = "가입 경로가 누락되었습니다.") - Provider provider -) { - public OAuthLoginInfo toDto() { - Map propertyMap = Map.of( - AuthConstants.CODE, code, - AuthConstants.REDIRECT_URI, redirectUri - ); - return new OAuthLoginInfo(provider, propertyMap); - } + @NotBlank(message = "인가코드가 누락되었습니다.") String code, + @NotNull(message = "리다이렉트 URI가 누락되었습니다.") String redirectUri, + @NotNull(message = "가입 경로가 누락되었습니다.") Provider provider) { + } diff --git a/src/main/java/com/juu/juulabel/common/exception/AuthException.java b/src/main/java/com/juu/juulabel/common/exception/AuthException.java new file mode 100644 index 00000000..7077aa06 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/exception/AuthException.java @@ -0,0 +1,24 @@ +package com.juu.juulabel.common.exception; + +import com.juu.juulabel.common.exception.code.ErrorCode; +import lombok.Getter; + +@Getter +public class AuthException extends BaseException { + + public AuthException() { + super(ErrorCode.HIGH_SECURITY_RISK); + } + + public AuthException(ErrorCode errorCode) { + super(errorCode); + } + + public AuthException(String message) { + super(message, ErrorCode.HIGH_SECURITY_RISK); + } + + public AuthException(String message, ErrorCode errorCode) { + super(message, errorCode); + } +} diff --git a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java index 982e9344..e842dc02 100644 --- a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java +++ b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java @@ -32,15 +32,23 @@ public enum ErrorCode { JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), /** - * Authentication + * Authorization */ DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "Device-Id 헤더가 필요합니다."), - OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "Provider를 찾을 수 없습니다."), + + /** + * AuthException + */ REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "토큰을 찾을 수 없습니다."), - DEVICE_ID_MISMATCH(HttpStatus.BAD_REQUEST, "Device-Id 불일치"), - REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), - REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.FORBIDDEN, "토큰 재사용 감지"), + REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용 감지"), + + SOCIAL_LINK_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 링크를 찾을 수 없습니다."), + SOCIAL_LINK_ALREADY_USED(HttpStatus.BAD_REQUEST, "소셜 링크가 이미 사용되었습니다."), + PROVIDER_ID_MISMATCH(HttpStatus.FORBIDDEN, "소셜 아이디 불일치"), + DEVICE_ID_MISMATCH(HttpStatus.FORBIDDEN, "Device-Id 불일치"), + USER_AGENT_MISMATCH(HttpStatus.FORBIDDEN, "User-Agent 불일치"), + HIGH_SECURITY_RISK(HttpStatus.FORBIDDEN, "높은 보안 위협이 감지되었습니다."), /** * Admin, Member diff --git a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java index a8befaea..8935abd7 100644 --- a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.juu.juulabel.common.exception.BaseException; import com.juu.juulabel.common.exception.CustomJwtException; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.AuthException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.response.CommonResponse; import io.sentry.Sentry; @@ -10,6 +12,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; + import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -29,36 +32,43 @@ public ResponseEntity> handle(Exception e) { return CommonResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage()); } + @ExceptionHandler(InvalidParamException.class) + public ResponseEntity> handle(InvalidParamException e) { + return CommonResponse.fail(e.getErrorCode()); + } + + @ExceptionHandler(AuthException.class) + public ResponseEntity> handle(AuthException e) { + log.error("AuthException :", e); + Sentry.captureException(e); + return CommonResponse.fail(e.getErrorCode(), e.getMessage()); + } + @ExceptionHandler(BaseException.class) public ResponseEntity> handle(BaseException e) { - log.error("BaseException :", e); Sentry.captureException(e); return CommonResponse.fail(e.getErrorCode()); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handle(MethodArgumentNotValidException e) { - log.error("MethodArgumentNotValidException :", e); return CommonResponse.fail(ErrorCode.VALIDATION_ERROR, Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage()); } @ExceptionHandler(CustomJwtException.class) public ResponseEntity> handle(CustomJwtException e) { - log.error("CustomJwtException :", e); return CommonResponse.fail(e.getErrorCode(), e.getMessage()); } @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity> handle(NoResourceFoundException e) { - log.warn("NoResourceFoundException : {}", e.getMessage()); return CommonResponse.fail(ErrorCode.NOT_FOUND); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity> handleValidationException(HttpMessageNotReadableException exception) { String errorDetails = ""; - log.error("HttpMessageNotReadableException :", exception); if (exception.getCause() instanceof InvalidFormatException invalidFormatException) { if (invalidFormatException.getTargetType() != null && invalidFormatException.getTargetType().isEnum()) { errorDetails = String.format("'%s'. 값은 다음 중 하나여야 합니다: %s.", @@ -74,9 +84,6 @@ public ResponseEntity> handleValidationException(HttpMess @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity> handle(MethodArgumentTypeMismatchException e) { - log.error("MethodArgumentTypeMismatchException :", e); - - // Check if the underlying cause is a BaseException Throwable cause = e.getCause(); while (cause != null) { if (cause instanceof BaseException baseException) { @@ -84,7 +91,7 @@ public ResponseEntity> handle(MethodArgumentTypeMismatchE } cause = cause.getCause(); } - + return CommonResponse.fail(ErrorCode.VALIDATION_ERROR, e.getMessage()); } } diff --git a/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java b/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java index de846d68..f8e9779e 100644 --- a/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java +++ b/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java @@ -3,66 +3,44 @@ import com.juu.juulabel.common.provider.oauth.GoogleProvider; import com.juu.juulabel.common.provider.oauth.KakaoProvider; import com.juu.juulabel.common.provider.oauth.OAuthProvider; +import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; + +import lombok.RequiredArgsConstructor; + import com.juu.juulabel.member.domain.Provider; import org.springframework.stereotype.Component; -import java.util.EnumMap; -import java.util.Map; -import java.util.Objects; - @Component +@RequiredArgsConstructor public class OAuthProviderFactory { - private final Map OAuthProviderMap; private final KakaoProvider kakaoProvider; private final GoogleProvider googleProvider; - public OAuthProviderFactory( - KakaoProvider kakaoProvider, - GoogleProvider googleProvider - ) { - OAuthProviderMap = new EnumMap<>(Provider.class); - this.kakaoProvider = kakaoProvider; - this.googleProvider = googleProvider; - initialize(); - } - - private void initialize() { - OAuthProviderMap.put(Provider.KAKAO, kakaoProvider); - OAuthProviderMap.put(Provider.GOOGLE, googleProvider); - } - private OAuthProvider getOAuthProvider(Provider provider) { - OAuthProvider oAuthProvider = OAuthProviderMap.get(provider); - if (Objects.isNull(oAuthProvider)) { - throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); - } - - return oAuthProvider; + return switch (provider) { + case KAKAO -> kakaoProvider; + case GOOGLE -> googleProvider; + default -> throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); + }; } - public String getAccessToken(Provider provider, String redirectUri, String code) { - return getOAuthToken(provider, redirectUri, code).accessToken(); + public OAuthUser getOAuthUser(OAuthLoginRequest oAuthLoginRequest) { + Provider provider = oAuthLoginRequest.provider(); + String accessToken = getOAuthToken( + provider, + oAuthLoginRequest.redirectUri(), + oAuthLoginRequest.code()).accessToken(); + + return getOAuthProvider(provider).getOAuthUser(accessToken); } private OAuthToken getOAuthToken(Provider provider, String redirectUri, String code) { return getOAuthProvider(provider).getOAuthToken(redirectUri, code); } - public String getProviderId(OAuthUser oAuthUser) { - return oAuthUser.id(); - } - - public String getEmail(OAuthUser oAuthUser) { - return oAuthUser.email(); - } - - public OAuthUser getOAuthUser(Provider provider, String accessToken) { - return getOAuthProvider(provider).getOAuthUser(accessToken); - } - } diff --git a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java index efdccfb5..71164da7 100644 --- a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java @@ -24,11 +24,6 @@ import java.util.*; import java.util.function.Function; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; - @Component public class JwtTokenProvider { private static final String ISSUER = "juulabel"; @@ -137,13 +132,4 @@ private Claims parseClaims(String token) { } } - public String hashToken(String token) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashedBytes = digest.digest(token.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(hashedBytes); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } - } } diff --git a/src/main/java/com/juu/juulabel/common/util/HashingUtil.java b/src/main/java/com/juu/juulabel/common/util/HashingUtil.java new file mode 100644 index 00000000..8353baf1 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/HashingUtil.java @@ -0,0 +1,23 @@ +package com.juu.juulabel.common.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +public final class HashingUtil { + + private HashingUtil() { + throw new AssertionError("Utility class should not be instantiated"); + } + + public static String hashSha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashedBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashedBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java index f2ebf42e..4f679bcd 100644 --- a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java +++ b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java @@ -69,7 +69,7 @@ public static String getClientIpAddress() { .map(ip -> ip.split(",")[0].trim()) .filter(IpAddressExtractor::isValidIpAddress) .findFirst() - .orElse(request.getRemoteAddr()); + .orElseGet(request::getRemoteAddr); return fallbackIp != null ? fallbackIp : "unknown"; }); diff --git a/src/main/java/com/juu/juulabel/follow/repository/FollowReader.java b/src/main/java/com/juu/juulabel/follow/repository/FollowReader.java index 0388b966..646ef2d3 100644 --- a/src/main/java/com/juu/juulabel/follow/repository/FollowReader.java +++ b/src/main/java/com/juu/juulabel/follow/repository/FollowReader.java @@ -22,7 +22,7 @@ public class FollowReader { public Follow findOrNullByFollowerAndFollowee(final Member follower, final Member followee) { return followJpaRepository.findByFollowerAndFollowee(follower, followee) - .orElse(null); + .orElseGet(() -> null); } public Slice findAllFollowing(final Member loginMember, diff --git a/src/main/java/com/juu/juulabel/member/domain/Member.java b/src/main/java/com/juu/juulabel/member/domain/Member.java index c9007e92..881df209 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Member.java +++ b/src/main/java/com/juu/juulabel/member/domain/Member.java @@ -2,6 +2,9 @@ import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.UpdateProfileRequest; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.base.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -14,9 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity -@Table( - name = "member" -) +@Table(name = "member") public class Member extends BaseTimeEntity { @Id @@ -25,13 +26,12 @@ public class Member extends BaseTimeEntity { private Long id; @Column(name = "email", nullable = false, unique = true, columnDefinition = "varchar(255) comment '이메일'") - // TODO : unique에 대한 커스텀 예외 처리 private String email; @Column(name = "name", columnDefinition = "varchar(45) comment '회원 이름'") private String name; - @Column(name = "nickname", nullable = false, columnDefinition = "varchar(45) comment '닉네임'") + @Column(name = "nickname", nullable = false, unique = true, columnDefinition = "varchar(45) comment '닉네임'") private String nickname; @Column(name = "introduction", columnDefinition = "varchar(600) comment '자기소개'") @@ -77,15 +77,15 @@ public class Member extends BaseTimeEntity { public static Member create(SignUpMemberRequest signUpMemberRequest) { return Member.builder() - .email(signUpMemberRequest.email()) - .nickname(signUpMemberRequest.nickname()) - .gender(signUpMemberRequest.gender()) - .provider(signUpMemberRequest.provider()) - .providerId(signUpMemberRequest.providerId()) - .status(MemberStatus.ACTIVE) - .hasBadge(false) - .role(MemberRole.ROLE_USER) - .build(); + .email(signUpMemberRequest.email()) + .nickname(signUpMemberRequest.nickname()) + .gender(signUpMemberRequest.gender()) + .provider(signUpMemberRequest.provider()) + .providerId(signUpMemberRequest.providerId()) + .status(MemberStatus.ACTIVE) + .hasBadge(false) + .role(MemberRole.ROLE_USER) + .build(); } public void updateProfile(UpdateProfileRequest request, String profileImageUrl) { @@ -104,10 +104,24 @@ public boolean isSameUser(Member other) { return this.equals(other); } + public void validateLoginMember(Provider provider, String providerId) { + if (this.deletedAt != null) { + throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); + } + if (!this.provider.equals(provider)) { + throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + if (!this.providerId.equals(providerId)) { + throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); + } + } + @Override public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; + if (this == obj) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; Member member = (Member) obj; return Objects.equals(this.id, member.id); } diff --git a/src/main/java/com/juu/juulabel/member/repository/MemberReader.java b/src/main/java/com/juu/juulabel/member/repository/MemberReader.java index 97284f86..0caabdc9 100644 --- a/src/main/java/com/juu/juulabel/member/repository/MemberReader.java +++ b/src/main/java/com/juu/juulabel/member/repository/MemberReader.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import java.util.List; +import java.util.Optional; @Reader @RequiredArgsConstructor @@ -28,6 +29,10 @@ public Member getByEmail(String email) { .orElseThrow(() -> new InvalidParamException(ErrorCode.MEMBER_NOT_FOUND)); } + public Optional getOptionalByEmail(String email) { + return memberJpaRepository.findByEmail(email); + } + public boolean existsByEmailAndProvider(String email, Provider provider) { return memberJpaRepository.existsByEmailAndProvider(email, provider); } diff --git a/src/main/java/com/juu/juulabel/member/repository/MemberWriter.java b/src/main/java/com/juu/juulabel/member/repository/MemberWriter.java index 5a6c02c5..513d597d 100644 --- a/src/main/java/com/juu/juulabel/member/repository/MemberWriter.java +++ b/src/main/java/com/juu/juulabel/member/repository/MemberWriter.java @@ -5,22 +5,88 @@ import com.juu.juulabel.common.annotation.Writer; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.repository.jpa.MemberJpaRepository; -import com.juu.juulabel.member.repository.jpa.MemberQueryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Writer @RequiredArgsConstructor public class MemberWriter { private final MemberJpaRepository memberJpaRepository; - private final MemberQueryRepository memberQueryRepository; + /** + * Stores a member entity to the database. + * Handles SQL constraint violations and converts them to appropriate business + * exceptions. + * + * @param member the member entity to store + * @throws InvalidParamException if nickname or email constraint violation + * occurs + */ public void store(Member member) { - memberJpaRepository.save(member); + try { + memberJpaRepository.save(member); + } catch (DataIntegrityViolationException e) { + handleConstraintViolation(e); + } } - public Member getByEmail(String email) { - return memberJpaRepository.findByEmail(email) - .orElseThrow(() -> new InvalidParamException(ErrorCode.MEMBER_NOT_FOUND)); + /** + * Handles database constraint violations and converts them to business + * exceptions. + * + * @param e the DataIntegrityViolationException to handle + * @throws InvalidParamException for known constraint violations + */ + private void handleConstraintViolation(DataIntegrityViolationException e) { + String errorMessage = getRootCauseMessage(e); + + if (errorMessage == null || errorMessage.isEmpty()) { + throw new InvalidParamException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + if (isNicknameConstraintViolation(errorMessage)) { + throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); + } + + if (isEmailConstraintViolation(errorMessage)) { + throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + + // Re-throw the original exception if it's not a recognized constraint violation + throw new InvalidParamException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + /** + * Extracts the root cause error message from the exception chain. + */ + private String getRootCauseMessage(DataIntegrityViolationException e) { + Throwable rootCause = e; + while (rootCause.getCause() != null) { + rootCause = rootCause.getCause(); + } + return rootCause.getMessage(); + } + + /** + * Checks if the error message indicates a nickname constraint violation. + */ + private boolean isNicknameConstraintViolation(String errorMessage) { + String lowerCaseMessage = errorMessage.toLowerCase(); + return lowerCaseMessage.contains("unique_nickname") || + (lowerCaseMessage.contains("duplicate entry") && lowerCaseMessage.contains("unique_nickname")); + } + + /** + * Checks if the error message indicates an email constraint violation. + */ + private boolean isEmailConstraintViolation(String errorMessage) { + String lowerCaseMessage = errorMessage.toLowerCase(); + return lowerCaseMessage.contains("unique_email") || + lowerCaseMessage.contains("uk_") || + (lowerCaseMessage.contains("duplicate entry") && + (lowerCaseMessage.contains("email") || lowerCaseMessage.contains("uk_"))); } } diff --git a/src/main/java/com/juu/juulabel/member/repository/WithdrawalRecordReader.java b/src/main/java/com/juu/juulabel/member/repository/WithdrawalRecordReader.java deleted file mode 100644 index f9ae0ef8..00000000 --- a/src/main/java/com/juu/juulabel/member/repository/WithdrawalRecordReader.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.juu.juulabel.member.repository; - -import com.juu.juulabel.common.annotation.Reader; -import com.juu.juulabel.member.repository.query.WithdrawalRecordQueryRepository; -import lombok.RequiredArgsConstructor; - -@Reader -@RequiredArgsConstructor -public class WithdrawalRecordReader { - - private final WithdrawalRecordQueryRepository withdrawalRecordQueryRepository; - - public boolean existEmail(String email) { - return withdrawalRecordQueryRepository.existEmail(email); - } -} diff --git a/src/main/java/com/juu/juulabel/member/request/OAuthLoginInfo.java b/src/main/java/com/juu/juulabel/member/request/OAuthLoginInfo.java deleted file mode 100644 index fc6a6b9c..00000000 --- a/src/main/java/com/juu/juulabel/member/request/OAuthLoginInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.juu.juulabel.member.request; - -import com.juu.juulabel.member.domain.Provider; - -import java.util.Map; - -public record OAuthLoginInfo( - Provider provider, - Map propertyMap -) { -} diff --git a/src/main/java/com/juu/juulabel/member/util/MemberUtils.java b/src/main/java/com/juu/juulabel/member/util/MemberUtils.java index b099a702..454d68c2 100644 --- a/src/main/java/com/juu/juulabel/member/util/MemberUtils.java +++ b/src/main/java/com/juu/juulabel/member/util/MemberUtils.java @@ -49,6 +49,22 @@ public class MemberUtils { private final AlcoholTypeReader alcoholTypeReader; private final MemberTermsWriter memberTermsWriter; + public void processMemberData(Member member, SignUpMemberRequest signUpRequest) { + try { + // Process alcohol types if provided + if (signUpRequest.alcoholTypeIds() != null && !signUpRequest.alcoholTypeIds().isEmpty()) { + processAlcoholTypes(member, signUpRequest); + } + + // Process terms agreements if provided + if (signUpRequest.termsAgreements() != null && !signUpRequest.termsAgreements().isEmpty()) { + processTermsAgreements(member, signUpRequest); + } + } catch (Exception e) { + throw new InvalidParamException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + public List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList, AlcoholTypeReader alcoholTypeReader) { return alcoholTypeIdList.stream() diff --git a/src/main/java/com/juu/juulabel/auth/executor/RedisScriptExecutor.java b/src/main/java/com/juu/juulabel/redis/RedisScriptExecutor.java similarity index 96% rename from src/main/java/com/juu/juulabel/auth/executor/RedisScriptExecutor.java rename to src/main/java/com/juu/juulabel/redis/RedisScriptExecutor.java index 8bb5673a..dd352c9f 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/RedisScriptExecutor.java +++ b/src/main/java/com/juu/juulabel/redis/RedisScriptExecutor.java @@ -1,4 +1,4 @@ -package com.juu.juulabel.auth.executor; +package com.juu.juulabel.redis; import org.springframework.data.redis.RedisSystemException; diff --git a/src/main/java/com/juu/juulabel/auth/executor/RedisScriptName.java b/src/main/java/com/juu/juulabel/redis/RedisScriptName.java similarity index 86% rename from src/main/java/com/juu/juulabel/auth/executor/RedisScriptName.java rename to src/main/java/com/juu/juulabel/redis/RedisScriptName.java index be473390..0459f196 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/RedisScriptName.java +++ b/src/main/java/com/juu/juulabel/redis/RedisScriptName.java @@ -1,10 +1,14 @@ -package com.juu.juulabel.auth.executor; +package com.juu.juulabel.redis; public enum RedisScriptName { + + // Refresh Token ROTATE_REFRESH_TOKEN("RotateRefreshTokenScriptExecutor"), LOGIN_REFRESH_TOKEN("LoginRefreshTokenScriptExecutor"), SAVE_REFRESH_TOKEN("SaveRefreshTokenScriptExecutor"), - REVOKE_REFRESH_TOKEN_BY_INDEX_KEY("RevokeRefreshTokenByIndexKeyExecutor"); + REVOKE_REFRESH_TOKEN_BY_INDEX_KEY("RevokeRefreshTokenByIndexKeyExecutor"), + + ; private final String executorName; diff --git a/src/main/java/com/juu/juulabel/auth/executor/ScriptRegistry.java b/src/main/java/com/juu/juulabel/redis/ScriptRegistry.java similarity index 94% rename from src/main/java/com/juu/juulabel/auth/executor/ScriptRegistry.java rename to src/main/java/com/juu/juulabel/redis/ScriptRegistry.java index b300067d..f49951ed 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/ScriptRegistry.java +++ b/src/main/java/com/juu/juulabel/redis/ScriptRegistry.java @@ -1,4 +1,4 @@ -package com.juu.juulabel.auth.executor; +package com.juu.juulabel.redis; import java.util.List; import java.util.Map; diff --git a/src/main/resources/scripts/login_refresh_token.lua b/src/main/resources/scripts/login_refresh_token.lua index 0568f5f1..6035426b 100644 --- a/src/main/resources/scripts/login_refresh_token.lua +++ b/src/main/resources/scripts/login_refresh_token.lua @@ -1,5 +1,5 @@ --- KEYS[1] = newTokenKey (e.g., "RefreshToken:{hashedToken}") --- KEYS[2] = indexKey (e.g., "RefreshIndex:{memberId}:{clientId}:{deviceId}") +-- KEYS[1] = newTokenKey (e.g., "refresh_token:{hashedToken}") +-- KEYS[2] = indexKey (e.g., "refresh_index:{memberId}:{clientId}:{deviceId}") -- ARGV[1] = memberId -- ARGV[2] = clientId -- ARGV[3] = deviceId diff --git a/src/main/resources/scripts/rotate_refresh_token.lua b/src/main/resources/scripts/rotate_refresh_token.lua index 4dd699e6..3a470ae9 100644 --- a/src/main/resources/scripts/rotate_refresh_token.lua +++ b/src/main/resources/scripts/rotate_refresh_token.lua @@ -1,6 +1,6 @@ --- KEYS[1] = new token key (e.g., "RefreshToken:{hashedToken}") --- KEYS[2] = indexKey (e.g., "RefreshIndex:{memberId}:{clientId}:{deviceId}") --- KEYS[3] = old token key (e.g., "RefreshToken:{hashedToken}") +-- KEYS[1] = new token key (e.g., "refresh_token:{hashedToken}") +-- KEYS[2] = indexKey (e.g., "refresh_index:{memberId}:{clientId}:{deviceId}") +-- KEYS[3] = old token key (e.g., "refresh_token:{hashedToken}") -- ARGV[1] = memberId -- ARGV[2] = clientId -- ARGV[3] = deviceId @@ -20,7 +20,7 @@ local ttl = tonumber(ARGV[6]) -- Helper function to revoke all member tokens local function revokeAllMemberTokens(memberId) local cursor = "0" - local pattern = "RefreshIndex:" .. memberId .. ":*" + local pattern = "refresh_index:" .. memberId .. ":*" repeat local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100) diff --git a/src/main/resources/scripts/save_refresh_token.lua b/src/main/resources/scripts/save_refresh_token.lua index 5742579c..f800b76a 100644 --- a/src/main/resources/scripts/save_refresh_token.lua +++ b/src/main/resources/scripts/save_refresh_token.lua @@ -1,5 +1,5 @@ --- KEYS[1] = newTokenKey (e.g., "RefreshToken:{hashedToken}") --- KEYS[2] = indexKey (e.g., "RefreshIndex:{memberId}:{clientId}:{deviceId}") +-- KEYS[1] = newTokenKey (e.g., "refresh_token:{hashedToken}") +-- KEYS[2] = indexKey (e.g., "refresh_index:{memberId}:{clientId}:{deviceId}") -- ARGV[1] = memberId -- ARGV[2] = clientId -- ARGV[3] = deviceId