From 4ebdf044e241fb3509bd6f57b7cdc0246427735e Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Thu, 29 May 2025 20:09:27 +0900 Subject: [PATCH 1/5] Refactor authentication API and token management system - Removed outdated Redis script executors and consolidated token management logic. - Introduced a new RedisScriptExecutor interface for better abstraction and handling of Redis scripts. - Enhanced error handling in token management, replacing BaseException with AuthException for specific authentication errors. - Updated AuthService to streamline login and sign-up processes, improving member validation and token creation. - Refactored OAuthLoginRequest to simplify OAuth handling and removed unnecessary classes for cleaner codebase. - Improved member repository methods for better retrieval and error handling. This refactor aims to enhance maintainability, security, and clarity in the authentication flow. --- ...-auth-api.md => PR-139-refactor---auth.md} | 0 ...40-refactor---public-access-control.md.md} | 0 docs/pr/PR-141-refactor---auth.md | 131 ++++++++++++++++++ .../juu/juulabel/auth/domain/SocialLink.java | 110 +++++++++++++++ .../LoginRefreshTokenScriptExecutor.java | 1 + .../RevokeRefreshTokenByIndexKeyExecutor.java | 2 + .../RotateRefreshTokenScriptExecutor.java | 8 +- .../SaveRefreshTokenScriptExecutor.java | 1 + ...y.java => RefreshTokenRepositoryImpl.java} | 7 +- .../auth/repository/SocialLinkRepository.java | 9 ++ .../juulabel/auth/service/AuthService.java | 95 ++++++------- .../auth/service/FraudDetectionService.java | 7 - .../juulabel/auth/service/RiskAssessment.java | 28 ---- .../auth/service/SocialLinkService.java | 47 +++++++ .../juulabel/auth/service/TokenService.java | 5 +- .../common/constants/AuthConstants.java | 12 +- .../common/dto/request/OAuthLoginRequest.java | 22 +-- .../common/exception/AuthException.java | 24 ++++ .../common/exception/code/ErrorCode.java | 19 ++- .../handler/GlobalExceptionHandler.java | 25 ++-- .../common/factory/OAuthProviderFactory.java | 58 +++----- .../common/provider/JwtTokenProvider.java | 14 -- .../juu/juulabel/common/util/HashingUtil.java | 23 +++ .../juu/juulabel/member/domain/Member.java | 46 +++--- .../member/repository/MemberReader.java | 5 + .../member/repository/MemberWriter.java | 78 ++++++++++- .../repository/WithdrawalRecordReader.java | 16 --- .../member/request/OAuthLoginInfo.java | 11 -- .../juu/juulabel/member/util/MemberUtils.java | 16 +++ .../RedisScriptExecutor.java | 2 +- .../executor => redis}/RedisScriptName.java | 8 +- .../executor => redis}/ScriptRegistry.java | 2 +- .../resources/scripts/login_refresh_token.lua | 4 +- .../scripts/rotate_refresh_token.lua | 8 +- .../resources/scripts/save_refresh_token.lua | 4 +- 35 files changed, 600 insertions(+), 248 deletions(-) rename docs/pr/{PR-139-refactor---auth-api.md => PR-139-refactor---auth.md} (100%) rename docs/pr/{PR-140-refactor--public-access-control.md.md => PR-140-refactor---public-access-control.md.md} (100%) create mode 100644 docs/pr/PR-141-refactor---auth.md create mode 100644 src/main/java/com/juu/juulabel/auth/domain/SocialLink.java rename src/main/java/com/juu/juulabel/auth/repository/{RedisRefreshTokenRepository.java => RefreshTokenRepositoryImpl.java} (89%) create mode 100644 src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java create mode 100644 src/main/java/com/juu/juulabel/common/exception/AuthException.java create mode 100644 src/main/java/com/juu/juulabel/common/util/HashingUtil.java delete mode 100644 src/main/java/com/juu/juulabel/member/repository/WithdrawalRecordReader.java delete mode 100644 src/main/java/com/juu/juulabel/member/request/OAuthLoginInfo.java rename src/main/java/com/juu/juulabel/{auth/executor => redis}/RedisScriptExecutor.java (96%) rename src/main/java/com/juu/juulabel/{auth/executor => redis}/RedisScriptName.java (86%) rename src/main/java/com/juu/juulabel/{auth/executor => redis}/ScriptRegistry.java (94%) 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..4e49aa47 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java @@ -0,0 +1,110 @@ +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("Suspicious activity detected Expected: " + this.provider + " " + + this.providerId + + " " + this.deviceId + " " + this.userAgent + " " + "Actual: " + provider + " " + providerId + " " + + deviceId + " " + userAgent); + } + } + + /** + * 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..cb53bd12 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) + .orElse(new Token(null, null)); - // Create refresh token for existing members - memberOpt.ifPresent(member -> tokenService.createLoginRefreshToken(member)); + Long memberId = memberOpt.map(Member::getId).orElse(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..5b45e45b 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_toekn"; + 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..966df922 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,24 @@ 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_MISMATCH(HttpStatus.FORBIDDEN, "Provider 불일치"), + 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/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 From 446884ef0c299fed25d7e6a258162fcfa766c7b2 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Thu, 29 May 2025 20:20:49 +0900 Subject: [PATCH 2/5] Refactor error handling and improve code clarity - Simplified error messages in SocialLink to enhance security and clarity. - Updated AuthService to use orElseGet for better performance and readability. - Corrected a typo in AuthConstants for the refresh token prefix. - Enhanced IpAddressExtractor to utilize orElseGet for improved null handling. - Refactored FollowReader to use orElseGet for consistency in null returns. These changes aim to improve maintainability and clarity across the authentication and follow management components. --- src/main/java/com/juu/juulabel/auth/domain/SocialLink.java | 5 +---- src/main/java/com/juu/juulabel/auth/service/AuthService.java | 4 ++-- .../com/juu/juulabel/common/constants/AuthConstants.java | 2 +- .../com/juu/juulabel/common/exception/code/ErrorCode.java | 1 - .../com/juu/juulabel/common/util/IpAddressExtractor.java | 2 +- .../com/juu/juulabel/follow/repository/FollowReader.java | 2 +- 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java index 4e49aa47..4ef6de6b 100644 --- a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java +++ b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java @@ -71,10 +71,7 @@ public void validate(Provider provider, String providerId, String deviceId, Stri // Validate parameters match stored values if (!isValidationParametersMatch(provider, providerId, deviceId, userAgent)) { - throw new AuthException("Suspicious activity detected Expected: " + this.provider + " " - + this.providerId - + " " + this.deviceId + " " + this.userAgent + " " + "Actual: " + provider + " " + providerId + " " - + deviceId + " " + userAgent); + throw new AuthException("Validation failed due to parameter mismatch"); } } 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 cb53bd12..e6c1bfa7 100644 --- a/src/main/java/com/juu/juulabel/auth/service/AuthService.java +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -60,9 +60,9 @@ public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { } Token accessToken = tokenService.createAccessToken(memberOpt) - .orElse(new Token(null, null)); + .orElseGet(() -> new Token(null, null)); - Long memberId = memberOpt.map(Member::getId).orElse(null); + Long memberId = memberOpt.map(Member::getId).orElseGet(() -> null); return new LoginResponse( accessToken, 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 5b45e45b..b92bddd6 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -17,7 +17,7 @@ public class AuthConstants { // Redis Prefix public static final String SOCIAL_LINK_PREFIX = "social_link"; - public static final String REFRESH_TOKEN_HASH_PREFIX = "refresh_toekn"; + 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/exception/code/ErrorCode.java b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java index 966df922..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 @@ -45,7 +45,6 @@ public enum ErrorCode { SOCIAL_LINK_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 링크를 찾을 수 없습니다."), SOCIAL_LINK_ALREADY_USED(HttpStatus.BAD_REQUEST, "소셜 링크가 이미 사용되었습니다."), - PROVIDER_MISMATCH(HttpStatus.FORBIDDEN, "Provider 불일치"), PROVIDER_ID_MISMATCH(HttpStatus.FORBIDDEN, "소셜 아이디 불일치"), DEVICE_ID_MISMATCH(HttpStatus.FORBIDDEN, "Device-Id 불일치"), USER_AGENT_MISMATCH(HttpStatus.FORBIDDEN, "User-Agent 불일치"), 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, From 8089ed22ef4463fb841d21fcdb902c2780c774e9 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Sat, 31 May 2025 20:49:32 +0900 Subject: [PATCH 3/5] Refactor authentication flow and enhance security measures - Resolved critical security vulnerabilities in the social login process. - Reduced unnecessary database calls during user registration and login, improving performance. - Clarified domain responsibilities and improved user experience with email validation. - Updated token management to utilize new providers for access and refresh tokens. - Enhanced error handling and introduced specific exceptions for authentication issues. These changes aim to strengthen security, optimize performance, and improve maintainability across the authentication system. --- docs/pr/PR-141-refactor---auth.md | 14 +- docs/pr/PR-142-refactor---auth.md | 149 +++++++++++++++++ .../admin/TestAccessTokenController.java | 17 +- .../juulabel/auth/controller/AuthApiDocs.java | 22 +-- .../auth/controller/AuthController.java | 35 ++-- .../juu/juulabel/auth/domain/SignUpToken.java | 11 ++ .../juu/juulabel/auth/domain/SocialLink.java | 23 ++- .../RotateRefreshTokenScriptExecutor.java | 10 +- .../juulabel/auth/service/AuthService.java | 149 ++++++++++------- .../auth/service/SocialLinkService.java | 26 +-- .../juulabel/auth/service/TokenService.java | 156 +++++++++--------- .../common/config/SecurityConfig.java | 18 +- .../common/constants/AuthConstants.java | 13 +- .../dto/request/SignUpMemberRequest.java | 20 +-- .../common/dto/response/LoginResponse.java | 10 +- .../dto/response/SignUpMemberResponse.java | 7 +- .../common/exception/code/ErrorCode.java | 4 +- .../handler/GlobalExceptionHandler.java | 4 + .../common/factory/OAuthProviderFactory.java | 33 ++-- .../common/filter/JwtAuthorizationFilter.java | 131 ++++++++++++--- .../common/properties/CookieProperties.java | 16 ++ .../common/provider/JwtTokenProvider.java | 135 --------------- .../provider/jwt/AccessTokenProvider.java | 49 ++++++ .../common/provider/jwt/JwtTokenProvider.java | 58 +++++++ .../provider/jwt/MemberTokenProvider.java | 39 +++++ .../provider/jwt/RefreshTokenProvider.java | 35 ++++ .../provider/jwt/SignupTokenProvider.java | 91 ++++++++++ .../common/util/AbstractHttpUtil.java | 4 - .../common/util/AuthorizationExtractor.java | 27 --- .../juu/juulabel/common/util/CookieUtil.java | 69 ++++++++ ...eIdExtractor.java => HttpRequestUtil.java} | 35 +++- .../common/util/HttpResponseUtil.java | 46 ------ .../common/util/UserAgentExtractor.java | 27 --- .../juu/juulabel/member/domain/Member.java | 16 +- .../juu/juulabel/member/domain/Provider.java | 5 +- .../juulabel/member/request/GoogleUser.java | 6 + .../juulabel/member/request/KakaoUser.java | 6 + .../juulabel/member/request/OAuthUser.java | 3 + .../juulabel/redis/RedisScriptExecutor.java | 7 +- 39 files changed, 976 insertions(+), 550 deletions(-) create mode 100644 docs/pr/PR-142-refactor---auth.md create mode 100644 src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java create mode 100644 src/main/java/com/juu/juulabel/common/properties/CookieProperties.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java create mode 100644 src/main/java/com/juu/juulabel/common/util/CookieUtil.java rename src/main/java/com/juu/juulabel/common/util/{DeviceIdExtractor.java => HttpRequestUtil.java} (54%) delete mode 100644 src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java diff --git a/docs/pr/PR-141-refactor---auth.md b/docs/pr/PR-141-refactor---auth.md index 09a6de5a..efb15a06 100644 --- a/docs/pr/PR-141-refactor---auth.md +++ b/docs/pr/PR-141-refactor---auth.md @@ -4,12 +4,12 @@ 이번 PR은 소셜 로그인 프로세스에 존재했던 **치명적인 보안 취약점**을 해결하고, 불필요한 데이터베이스 호출을 줄이며 도메인 책임을 명확히 했습니다. -| 항목 | Before | After | 결과 | -| :------------------- | :------------------------ | :--------------------- | :------------------------ | -| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** | -| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** | -| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** | -| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** | +| 항목 | Before | After | 결과 | +| :------------------- | :------------------------------ | :------------ | :--------------------- | +| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** | +| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** | +| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** | +| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** | --- @@ -127,5 +127,3 @@ final OAuthUser oAuthUser = providerFactory.getOAuthUser(oAuthLoginRequest); • 인증/인가 실패 상황(providerId 불일치, 로그인되지 않은 사용자, 토큰 유효성 문제 등)에 대해 별도 예외 클래스를 정의 • BaseException으로부터 상속, ErrorCode를 명확히 지정 • 차후 로그 필터링/슬랙 알림/보안 모니터링 등에서 인증 이슈만 별도로 추적 가능 - - diff --git a/docs/pr/PR-142-refactor---auth.md b/docs/pr/PR-142-refactor---auth.md new file mode 100644 index 00000000..55c95faf --- /dev/null +++ b/docs/pr/PR-142-refactor---auth.md @@ -0,0 +1,149 @@ +# 인증 시스템 보안 강화 및 아키텍처 리팩토링 (PR [#142](https://github.com/juulabel/juulabel-back/pull/144)) + +## TL;DR + +본 PR은 소셜 인증 시스템의 종합적인 보안 개선을 구현하여, 여러 중요한 보안 취약점을 해결하고 전체적인 아키텍처를 개선합니다. CSRF 보호, 토큰 분리, 그리고 간소화된 API 계약을 도입합니다. + +1. **보안 강화**: CSRF 보호 및 안전한 쿠키 처리 구현 +2. **아키텍처 단순화**: API 표면 영역 축소 및 미사용 필드 제거 +3. **토큰 보안**: 토큰 시크릿 분리 및 적절한 토큰 생명주기 관리 +4. **사용자 경험**: 보안 강화를 유지하면서 원활한 인증 플로우 제공 + +## 🔧 기술적 변경사항 + +### 1. 로그인 엔드포인트 리팩토링 (`/v1/api/auth/login/{provider}`) + +#### 응답 바디 최적화 + +**변경 전**: 미사용 필드가 포함된 복잡한 응답 +**변경 후**: 필수 데이터 플로우에 집중한 간소화된 응답 + +```java +public record LoginResponse( + String accessToken, // 기존 사용자의 경우 제공, 신규 사용자의 경우 null + String signUpToken, // 신규 사용자의 경우 제공, 기존 사용자의 경우 null + String email // 식별을 위해 항상 제공 +) {} +``` + +**근거**: + +- 미사용 필드를 통한 데이터 누출 방지 +- 기존 사용자 로그인과 신규 사용자 회원가입 플로우의 명확한 분리 +- 클라이언트 측 상태 관리 단순화 + +#### CSRF 보호 구현 + +- **쿠키**: 로그인 시 `CSRF-TOKEN`을 쿠키로 저장 +- **사용처**: 후속 토큰 갱신 작업에 필요 +- **보안**: 민감한 토큰 작업에 대한 CSRF 공격 방지 + +### 2. 회원가입 엔드포인트 강화 (`/v1/api/auth/sign-up`) + +#### Authorization 헤더 요구사항 + +```http +Authorization: Bearer {signUpToken} +``` + +**보안 기능**: + +- **토큰 검증**: 회원가입 토큰이 로그인 세션 데이터와 일치하는지 확인 +- **시간 검증**: 15분 만료 기간 (초과 시 401 반환) +- **무결성 검사**: 토큰 불일치/변조 시 403 반환 + +#### 요청 바디 단순화 + +**제거된 필드** (현재 signUpToken 페이로드에 포함): + +- `email` - 토큰 페이로드에서 추출 +- `provider` - 토큰 페이로드에서 추출 +- `providerId` - 토큰 페이로드에서 추출 + +**이점**: + +- 요청과 토큰 간의 데이터 불일치 방지 +- 데이터 조작에 대한 공격 표면 축소 +- 단일 진실 소스 원칙 적용 + +#### 응답 최적화 + +```java +public record SignUpMemberResponse( + Long memberId, + String accessToken +) {} +``` + +### 3. 토큰 갱신 보안 (`/v1/api/auth/refresh`) + +#### CSRF 헤더 검증 + +```http +X-CSRF-TOKEN: {csrfToken} +``` + +**구현 세부사항**: + +- 쿠키 값과 헤더 값이 일치해야 함 +- 각 요청마다 토큰 자동 갱신 +- 토큰 누락/무효 시 403 오류 반환 + +#### 보안 설정 + +```java +.csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) + .requireCsrfProtectionMatcher(request -> + request.getServletPath().equals("/v1/api/auth/refresh"))) +``` + +## 🔐 보안 강화사항 + +### 토큰 시크릿 분리 + +- **Access Token**: 사용자 세션 전용 시크릿 +- **Refresh Token**: 토큰 갱신 전용 시크릿 +- **Signup Token**: 등록 플로우 전용 시크릿 + +**이점**: 시크릿 침해 시 피해 범위 제한 + +## 🔄 마이그레이션 전략 + +### Breaking Changes + +1. **API 계약 변경**: 클라이언트 애플리케이션이 새 응답 형식으로 업데이트 필요 +2. **헤더 요구사항**: 회원가입 요청에 Authorization 헤더 필요 +3. **CSRF 헤더**: 갱신 요청에 X-CSRF-TOKEN 헤더 필요 + +### 호환성 고려사항 + +- 전환 기간 동안 기존 액세스 토큰 유효 유지 +- 프로덕션 배포 시 점진적 롤아웃 권장 +- 헤더 관리를 위한 클라이언트 측 변경 필요 + +## 🚀 향후 고려사항 + +### OAuth 2.0 PKCE 구현 + +- **상태**: 다음 반복에서 계획됨 +- **접근방식**: 프론트엔드 팀과 협력하여 클라이언트 측 구현 +- **이점**: 공개 OAuth 클라이언트에 대한 보안 강화 + +### 쿠키 보안 강화 + +**현재 구현**: 도메인 마이그레이션 전략 + +- **기존**: `juulabel.com/app` +- **변경**: `m.juulabel.com` +- **API 도메인**: `api.juulabel.com` +- **SameSite**: CSRF 보호 강화를 위한 Strict 정책 + +--- + +**리뷰 체크리스트**: + +- [ ] CSRF 보호 검증, 엔드투엔드 인증 플로우 +- [ ] 도메인 마이그레이션 전략 검토 +- [ ] 모든 인증 플로우에 대한 QA 검증 diff --git a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java b/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java index dda372b8..a7754f21 100644 --- a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java +++ b/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java @@ -1,7 +1,5 @@ package com.juu.juulabel.admin; - -import com.juu.juulabel.common.provider.JwtTokenProvider; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; @@ -10,26 +8,21 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.juu.juulabel.common.provider.jwt.AccessTokenProvider; -@Tag( - name = "테스트 API", - description = "테스트 API" -) +@Tag(name = "테스트 API", description = "테스트 API") @RestController @RequiredArgsConstructor public class TestAccessTokenController { - private final JwtTokenProvider jwtTokenProvider; + private final AccessTokenProvider accessTokenProvider; private final MemberService memberService; - @Operation( - summary = "JWT 테스트용 토큰 발급 API", - description = "기본 rldh11111@naver.com 이메일로 JWT 발급" - ) + @Operation(summary = "JWT 테스트용 토큰 발급 API", description = "기본 rldh11111@naver.com 이메일로 JWT 발급") @GetMapping("/token") public String testAccessToken(@RequestParam(defaultValue = "rldh11111@naver.com") String email) { Member member = memberService.getMemberByEmail(email); - return jwtTokenProvider.createAccessToken(member); + return accessTokenProvider.createToken(member); } } diff --git a/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java b/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java index c661b540..0aa8468e 100644 --- a/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java @@ -8,10 +8,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import com.juu.juulabel.auth.domain.SignUpToken; import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; @@ -26,6 +28,7 @@ import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; + import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Schema; @@ -33,29 +36,31 @@ @RequestMapping("/v1/api/auth") public interface AuthApiDocs { - @Operation(summary = "OAuth 소셜 로그인", description = "지원되는 OAuth 제공자(Google, Kakao)를 통한 로그인") + @Operation(summary = "OAuth 소셜 로그인 콜백", description = "지원되는 OAuth 제공자(Google, Kakao)를 통한 로그인 콜백") @ApiResponse(responseCode = "200", description = "로그인 성공", headers = { @Header(name = "Set-Cookie", description = "계정이 존재할시만 리프레시 토큰 발급", schema = @Schema(type = "string")) }) @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") @ApiResponse(responseCode = "401", description = "인증 실패") @PostMapping("/login/{provider}") - public ResponseEntity> oauthLogin( + public ResponseEntity> login( @Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider, - @Valid @RequestBody OAuthLoginRequest requestBody); + CsrfToken csrfToken, + @Valid @RequestBody OAuthLoginRequest request); @Operation(summary = "회원가입", description = "새로운 회원 등록 및 초기 토큰 발급") @ApiResponse(responseCode = "200", description = "회원가입 성공", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 발급", schema = @Schema(type = "string")) + @Header(name = "Set-Cookie", description = "리프레시 토큰 발급", schema = @Schema(type = "string")), }) @ApiResponse(responseCode = "400", description = "유효성 검사 실패, 중복된 이메일 또는 닉네임") @PostMapping("/sign-up") public ResponseEntity> signUp( + @AuthenticationPrincipal SignUpToken signUpToken, @Valid @RequestBody SignUpMemberRequest request); - @Operation(summary = "액세스 토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰 발급") + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 토큰 로테이션") @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 갱신", schema = @Schema(type = "string")) + @Header(name = "Set-Cookie", description = "리프레시 토큰 갱신", schema = @Schema(type = "string")), }) @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰", headers = { @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) @@ -65,8 +70,7 @@ public ResponseEntity> signUp( }) @PostMapping("/refresh") public ResponseEntity> refresh( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, - @AuthenticationPrincipal Member member); + @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken); @Operation(summary = "로그아웃", description = "현재 디바이스의 리프레시 토큰 무효화") @ApiResponse(responseCode = "200", description = "로그아웃 성공", headers = { @@ -75,7 +79,6 @@ public ResponseEntity> refresh( @ApiResponse(responseCode = "401", description = "인증되지 않은 요청") @PostMapping("/logout") public ResponseEntity> logout( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, @AuthenticationPrincipal Member member); @Operation(summary = "회원 탈퇴", description = "회원 계정 삭제 및 모든 토큰 무효화") @@ -86,7 +89,6 @@ public ResponseEntity> logout( @ApiResponse(responseCode = "401", description = "인증되지 않은 요청") @DeleteMapping("/me") public ResponseEntity> deleteAccount( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, @AuthenticationPrincipal Member member, @Valid @RequestBody WithdrawalRequest request); diff --git a/src/main/java/com/juu/juulabel/auth/controller/AuthController.java b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java index 2bb03848..4765cd70 100644 --- a/src/main/java/com/juu/juulabel/auth/controller/AuthController.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java @@ -1,5 +1,6 @@ package com.juu.juulabel.auth.controller; +import com.juu.juulabel.auth.domain.SignUpToken; import com.juu.juulabel.auth.service.AuthService; import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.dto.request.OAuthLoginRequest; @@ -13,12 +14,12 @@ import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.Provider; -import io.swagger.v3.oas.annotations.Parameter; - import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; + import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.*; @RestController @@ -28,51 +29,45 @@ public class AuthController implements AuthApiDocs { private final AuthService authService; @Override - public ResponseEntity> oauthLogin( - @Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider, - @Valid @RequestBody OAuthLoginRequest requestBody) { - - LoginResponse loginResponse = authService.login(requestBody); + public ResponseEntity> login( + @PathVariable Provider provider, + CsrfToken csrfToken, + @Valid @RequestBody OAuthLoginRequest request) { + csrfToken.getToken(); - return CommonResponse.success(SuccessCode.SUCCESS, loginResponse); + return CommonResponse.success(SuccessCode.SUCCESS, authService.login(request)); } @Override public ResponseEntity> signUp( + @AuthenticationPrincipal SignUpToken signUpToken, @Valid @RequestBody SignUpMemberRequest request) { - SignUpMemberResponse signUpMemberResponse = authService.signUp(request); - - return CommonResponse.success(SuccessCode.SUCCESS, signUpMemberResponse); + return CommonResponse.success(SuccessCode.SUCCESS, authService.signUp(signUpToken, request)); } @Override public ResponseEntity> refresh( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, - @AuthenticationPrincipal Member member) { - - RefreshResponse refreshResponse = authService.refresh(refreshToken); + @CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken) { - return CommonResponse.success(SuccessCode.SUCCESS, refreshResponse); + return CommonResponse.success(SuccessCode.SUCCESS, authService.refresh(refreshToken)); } @Override public ResponseEntity> logout( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, @AuthenticationPrincipal Member member) { - authService.logout(refreshToken); + authService.logout(member.getId()); return CommonResponse.success(SuccessCode.SUCCESS); } @Override public ResponseEntity> deleteAccount( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, @AuthenticationPrincipal Member member, @Valid @RequestBody WithdrawalRequest request) { - authService.deleteAccount(member, request, refreshToken); + authService.deleteAccount(member, request); return CommonResponse.success(SuccessCode.SUCCESS_DELETE); } diff --git a/src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java b/src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java new file mode 100644 index 00000000..275b5722 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java @@ -0,0 +1,11 @@ +package com.juu.juulabel.auth.domain; + +import com.juu.juulabel.member.domain.Provider; + +public record SignUpToken( + String token, + String email, + Provider provider, + String providerId, + String nonce) { +} diff --git a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java index 4ef6de6b..932ac67c 100644 --- a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java +++ b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java @@ -14,6 +14,7 @@ import com.juu.juulabel.common.exception.AuthException; import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.HttpRequestUtil; import com.juu.juulabel.member.domain.Provider; import lombok.Builder; @@ -43,12 +44,14 @@ public class SocialLink implements Serializable { private Long usedAt; + private String nonce; + @TimeToLive(unit = TimeUnit.SECONDS) private Long ttl; @Builder public SocialLink(String hashedEmail, Provider provider, String providerId, String deviceId, String userAgent, - String ipAddress) { + String ipAddress, String nonce) { this.hashedEmail = hashedEmail; this.provider = provider; this.providerId = providerId; @@ -56,6 +59,7 @@ public SocialLink(String hashedEmail, Provider provider, String providerId, Stri this.userAgent = userAgent; this.ipAddress = ipAddress; this.usedAt = null; + this.nonce = nonce; this.ttl = SOCIAL_LINK_DURATION.getSeconds(); } @@ -63,14 +67,15 @@ public SocialLink(String hashedEmail, Provider provider, String providerId, Stri * 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) { + public void validate(SignUpToken signUpToken) { // 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)) { + // DISABLE IN DEVELOPMENT ENVIRONMENT + if (!isValidationParametersMatch(signUpToken)) { throw new AuthException("Validation failed due to parameter mismatch"); } } @@ -97,11 +102,11 @@ public boolean isAlreadyUsed() { * 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); + private boolean isValidationParametersMatch(SignUpToken signUpToken) { + return Objects.equals(this.provider, signUpToken.provider()) && + Objects.equals(this.providerId, signUpToken.providerId()) && + Objects.equals(this.deviceId, HttpRequestUtil.getDeviceId()) && + Objects.equals(this.userAgent, HttpRequestUtil.getUserAgent()) && + Objects.equals(this.nonce, signUpToken.nonce()); } } 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 e38f1185..175cc521 100644 --- a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java +++ b/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java @@ -13,11 +13,8 @@ import org.springframework.stereotype.Component; 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 @@ -26,8 +23,10 @@ public class RotateRefreshTokenScriptExecutor implements RedisScriptExecutor redisTemplate; private final RedisScript redisScript; - public RotateRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { + public RotateRefreshTokenScriptExecutor(RedisTemplate redisTemplate) + throws IOException { this.redisTemplate = redisTemplate; + String scriptText = Files.readString( new ClassPathResource("scripts/rotate_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); this.redisScript = RedisScript.of(scriptText, Object.class); @@ -52,7 +51,6 @@ public Object execute(RefreshToken refreshToken, Object... args) { @Override public void handleRedisScriptError(String errorMessage) { - HttpResponseUtil.addCookie(AuthConstants.REFRESH_TOKEN_HEADER_NAME, "", 0); if (errorMessage.contains("OLD_TOKEN_NOT_FOUND")) { throw new AuthException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); } else if (errorMessage.contains("OLD_TOKEN_ALREADY_REVOKED_ALL_TOKENS_INVALIDATED")) { @@ -60,7 +58,7 @@ public void handleRedisScriptError(String errorMessage) { } else if (errorMessage.contains("DEVICE_ID_MISMATCH")) { throw new AuthException(ErrorCode.DEVICE_ID_MISMATCH); } else { - throw new BaseException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); + throw new AuthException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); } } } 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 e6c1bfa7..0e90e973 100644 --- a/src/main/java/com/juu/juulabel/auth/service/AuthService.java +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -1,5 +1,7 @@ package com.juu.juulabel.auth.service; +import com.juu.juulabel.auth.domain.SignUpToken; +import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; import com.juu.juulabel.common.dto.response.LoginResponse; @@ -11,22 +13,27 @@ import com.juu.juulabel.member.repository.MemberReader; import com.juu.juulabel.member.repository.MemberWriter; import com.juu.juulabel.member.repository.WithdrawalRecordWriter; -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 lombok.extern.slf4j.Slf4j; import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.member.request.OAuthUserInfo; -import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import java.util.Optional; +import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Service for handling authentication operations including login, signup, + * refresh, logout, and account deletion. + * Provides secure OAuth-based authentication with token management. + */ +@Slf4j @Service @RequiredArgsConstructor public class AuthService { + private final MemberReader memberReader; private final MemberWriter memberWriter; private final WithdrawalRecordWriter withdrawalRecordWriter; @@ -35,82 +42,112 @@ public class AuthService { private final TokenService tokenService; private final SocialLinkService socialLinkService; + /** + * Handles OAuth login for both new and existing members. + * For new members, creates a signup token; for existing members, creates an + * access token. + * + * @param request OAuth login request containing provider and authorization code + * @return LoginResponse with access token (existing user) or signup token (new + * user) + */ @Transactional - public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { - // 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); - } - - Token accessToken = tokenService.createAccessToken(memberOpt) - .orElseGet(() -> new Token(null, null)); - - Long memberId = memberOpt.map(Member::getId).orElseGet(() -> null); - - return new LoginResponse( - accessToken, - isNewMember, - new OAuthUserInfo( - memberId, - email, - providerId, - provider)); + public LoginResponse login(OAuthLoginRequest request) { + + final OAuthUser oAuthUser = providerFactory.getOAuthUser(request); + final Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); + + return memberOpt + .map(member -> createExistingMemberResponse(member, oAuthUser)) + .orElseGet(() -> createNewMemberResponse(oAuthUser)); } + /** + * Creates login response for existing members. + */ + private LoginResponse createExistingMemberResponse(Member member, OAuthUser oAuthUser) { + member.validateLoginMember(oAuthUser); + final String accessToken = tokenService.login(member); + return new LoginResponse(accessToken, null, oAuthUser.email()); + } + + /** + * Creates login response for new members (signup flow). + */ + private LoginResponse createNewMemberResponse(OAuthUser oAuthUser) { + final String nonce = UUID.randomUUID().toString(); + socialLinkService.save(oAuthUser, nonce); + final String signUpToken = tokenService.createSignUpReadyToken(oAuthUser, nonce); + return new LoginResponse(null, signUpToken, oAuthUser.email()); + } + + /** + * Completes member registration using a validated signup token. + * Creates the member, processes additional data, and generates authentication + * tokens. + * + * @param signUpToken validated signup token containing OAuth user information + * @param signUpRequest member registration details + * @return SignUpMemberResponse with the new member's ID + */ @Transactional - public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { - socialLinkService.verify(signUpRequest.email(), signUpRequest.provider(), signUpRequest.providerId()); + public SignUpMemberResponse signUp(SignUpToken signUpToken, SignUpMemberRequest signUpRequest) { - final Member member = Member.create(signUpRequest); + final Member member = Member.create(signUpRequest, signUpToken); memberWriter.store(member); + // Process additional member data (alcohol types, terms agreements) if provided memberUtils.processMemberData(member, signUpRequest); - // Create token pair for new member - final Token token = tokenService.createTokenPair(member); + // Generate authentication tokens for the new member + String accessToken = tokenService.signUp(member); - return new SignUpMemberResponse(member.getId(), token); + return new SignUpMemberResponse(member.getId(), accessToken); } - public RefreshResponse refresh(String oldToken) { - final Token newToken = tokenService.rotateRefreshToken(oldToken); - return new RefreshResponse(newToken.accessToken()); + /** + * Refreshes an access token using a valid refresh token. + * + * @param refreshToken the current refresh token + * @return RefreshResponse with the new access token + */ + @Transactional(readOnly = true) + public RefreshResponse refresh(String refreshToken) { + final String accessToken = tokenService.rotate(refreshToken); + return new RefreshResponse(accessToken); } - public void logout(String oldToken) { - tokenService.revokeRefreshToken(oldToken); + /** + * Logs out a member by revoking their tokens. + * + * @param memberId the ID of the member to log out + */ + @Transactional + public void logout(Long memberId) { + tokenService.logout(memberId); } + /** + * Permanently deletes a member account and creates a withdrawal record. + * This operation revokes all tokens and marks the member as deleted. + * + * @param loginMember the authenticated member requesting account deletion + * @param request withdrawal request containing the reason + */ @Transactional - public void deleteAccount(Member loginMember, WithdrawalRequest request, String oldToken) { - // Mark member as deleted + public void deleteAccount(Member loginMember, WithdrawalRequest request) { + + // Mark member as deleted (soft delete) loginMember.deleteAccount(); - // Create withdrawal record + // Create audit record for withdrawal final WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname()); withdrawalRecordWriter.store(withdrawalRecord); - // Revoke all tokens - tokenService.revokeAllRefreshTokens(oldToken); + // Revoke all authentication tokens + tokenService.withdraw(loginMember.getId()); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java b/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java index fafc856f..c835853f 100644 --- a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java +++ b/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java @@ -4,13 +4,13 @@ import lombok.RequiredArgsConstructor; +import com.juu.juulabel.auth.domain.SignUpToken; 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.HttpRequestUtil; 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.member.request.OAuthUser; import com.juu.juulabel.common.exception.AuthException; import com.juu.juulabel.common.exception.code.ErrorCode; @@ -20,26 +20,26 @@ public class SocialLinkService { private final SocialLinkRepository socialLinkRepository; - public void save(String email, Provider provider, String providerId) { + public void save(OAuthUser oAuthUser, String nonce) { SocialLink socialLink = SocialLink.builder() - .hashedEmail(HashingUtil.hashSha256(email)) - .provider(provider) - .providerId(providerId) - .deviceId(DeviceIdExtractor.getDeviceId()) - .userAgent(UserAgentExtractor.getUserAgent()) + .hashedEmail(HashingUtil.hashSha256(oAuthUser.email())) + .provider(oAuthUser.provider()) + .providerId(oAuthUser.id()) + .deviceId(HttpRequestUtil.getDeviceId()) + .userAgent(HttpRequestUtil.getUserAgent()) .ipAddress(IpAddressExtractor.getClientIpAddress()) + .nonce(nonce) .build(); socialLinkRepository.save(socialLink); } - public void verify(String email, Provider provider, String providerId) { - String hashedEmail = HashingUtil.hashSha256(email); + public void verify(SignUpToken signUpToken) { + String hashedEmail = HashingUtil.hashSha256(signUpToken.email()); SocialLink socialLink = socialLinkRepository.findById(hashedEmail) .orElseThrow(() -> new AuthException(ErrorCode.SOCIAL_LINK_NOT_FOUND)); - socialLink.validate(provider, providerId, DeviceIdExtractor.getDeviceId(), - UserAgentExtractor.getUserAgent()); + socialLink.validate(signUpToken); 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 c90088a3..108074e3 100644 --- a/src/main/java/com/juu/juulabel/auth/service/TokenService.java +++ b/src/main/java/com/juu/juulabel/auth/service/TokenService.java @@ -4,129 +4,135 @@ import com.juu.juulabel.auth.domain.RefreshToken; import com.juu.juulabel.auth.repository.RefreshTokenRepository; 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.provider.jwt.AccessTokenProvider; +import com.juu.juulabel.common.provider.jwt.RefreshTokenProvider; +import com.juu.juulabel.common.provider.jwt.SignupTokenProvider; +import com.juu.juulabel.common.util.CookieUtil; +import com.juu.juulabel.common.util.HttpRequestUtil; 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; import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.token.Token; +import com.juu.juulabel.member.request.OAuthUser; + import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -@Slf4j +/** + * Service for managing authentication tokens including access, refresh, and + * signup tokens. + * Handles token creation, rotation, revocation, and cookie management. + */ @Service @RequiredArgsConstructor public class TokenService { - private final JwtTokenProvider jwtTokenProvider; + private final AccessTokenProvider accessTokenProvider; + private final RefreshTokenProvider refreshTokenProvider; + private final SignupTokenProvider signupTokenProvider; private final RefreshTokenRepository refreshTokenRepository; /** - * Creates access and refresh tokens for a member + * Creates and sets tokens for member registration. + * Clears any existing signup token upon successful registration. + * + * @param member the member to create tokens for */ @Transactional - public Token createTokenPair(Member member) { - String accessToken = jwtTokenProvider.createAccessToken(member); - RefreshToken refreshToken = createRefreshToken(member); - - refreshTokenRepository.save(refreshToken); - setRefreshTokenCookie(refreshToken.getToken()); - - return new Token(accessToken, jwtTokenProvider.getExpirationByToken(accessToken)); + public String signUp(Member member) { + return createAccessAndRefreshToken(member, refreshTokenRepository::save); } /** - * Creates access token only (for existing members during login) + * Creates signup ready token for OAuth flow with enhanced validation. + * + * @param oAuthUser the OAuth user information + * @param nonce the security nonce */ - public Optional createAccessToken(Optional memberOpt) { - return memberOpt.map(member -> { - String accessToken = jwtTokenProvider.createAccessToken(member); - return new Token(accessToken, jwtTokenProvider.getExpirationByToken(accessToken)); - }); + public String createSignUpReadyToken(OAuthUser oAuthUser, String nonce) { + return signupTokenProvider.createToken(oAuthUser, nonce); } /** - * Creates refresh token for login (revokes existing tokens for same device) + * Creates tokens for login and manages device-specific token rotation. + * + * @param member the member to create tokens for */ @Transactional - public void createLoginRefreshToken(Member member) { - - RefreshToken refreshToken = createRefreshToken(member); - - refreshTokenRepository.login(refreshToken); - setRefreshTokenCookie(refreshToken.getToken()); + public String login(Member member) { + return createAccessAndRefreshToken(member, refreshTokenRepository::login); } /** - * Rotates refresh token + * Rotates refresh token for enhanced security. + * Implements secure token rotation to prevent token replay attacks. + * + * @param oldToken the current refresh token to rotate */ @Transactional - public Token rotateRefreshToken(String oldToken) { - Member member = jwtTokenProvider.getMemberFromToken(oldToken); - String hashedOldToken = HashingUtil.hashSha256(oldToken); + public String rotate(String oldToken) { - RefreshToken newRefreshToken = createRefreshToken(member); + final Member member = refreshTokenProvider.getMemberFromToken(oldToken); + final String hashedOldToken = HashingUtil.hashSha256(oldToken); + final RefreshToken newRefreshToken = refreshTokenProvider.buildRefreshToken(member); refreshTokenRepository.rotate(newRefreshToken, hashedOldToken); + CookieUtil.addCookie(AuthConstants.REFRESH_TOKEN_NAME, newRefreshToken.getToken(), + (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); - setRefreshTokenCookie(newRefreshToken.getToken()); - - String newAccessToken = jwtTokenProvider.createAccessToken(member); - return new Token(newAccessToken, jwtTokenProvider.getExpirationByToken(newAccessToken)); + return accessTokenProvider.createToken(member); } /** - * Revokes refresh token (logout) + * Revokes refresh token for logout with device-specific cleanup. + * + * @param memberId the member ID for logout */ @Transactional - public void revokeRefreshToken(String token) { - Member member = jwtTokenProvider.getMemberFromToken(token); - String deviceId = DeviceIdExtractor.getDeviceId(); - - refreshTokenRepository.revokeByMemberAndDevice(member.getId(), ClientId.WEB, deviceId); - clearRefreshTokenCookie(); + public void logout(Long memberId) { + final String deviceId = HttpRequestUtil.getDeviceId(); + refreshTokenRepository.revokeByMemberAndDevice(memberId, ClientId.WEB, deviceId); + CookieUtil.removeCookie(AuthConstants.REFRESH_TOKEN_NAME); } /** - * Revokes all refresh tokens for a member (account deletion) + * Revokes all refresh tokens for account withdrawal. + * Performs complete token cleanup for account deletion. + * + * @param memberId the member ID for account withdrawal */ @Transactional - public void revokeAllRefreshTokens(String token) { - Member member = jwtTokenProvider.getMemberFromToken(token); - - refreshTokenRepository.revokeAllByMember(member.getId()); - clearRefreshTokenCookie(); + public void withdraw(Long memberId) { + refreshTokenRepository.revokeAllByMember(memberId); + CookieUtil.removeCookie(AuthConstants.REFRESH_TOKEN_NAME); } - private RefreshToken createRefreshToken(Member member) { - String token = jwtTokenProvider.createRefreshToken(member); - String hashedToken = HashingUtil.hashSha256(token); - - return RefreshToken.builder() - .token(token) - .hashedToken(hashedToken) - .memberId(member.getId()) - .clientId(ClientId.WEB) - .deviceId(DeviceIdExtractor.getDeviceId()) - .ipAddress(IpAddressExtractor.getClientIpAddress()) - .userAgent(UserAgentExtractor.getUserAgent()) - .build(); - } + /** + * Common method for creating and setting tokens with different repository + * operations. + * Centralizes token creation logic to reduce code duplication. + * + * @param member the member to create tokens for + * @param repositoryOperation the repository operation to perform + */ + private String createAccessAndRefreshToken(Member member, RepositoryOperation repositoryOperation) { + + final RefreshToken refreshToken = refreshTokenProvider.buildRefreshToken(member); - private void setRefreshTokenCookie(String token) { - HttpResponseUtil.addCookie( - AuthConstants.REFRESH_TOKEN_HEADER_NAME, - token, + repositoryOperation.execute(refreshToken); + + CookieUtil.addCookie(AuthConstants.REFRESH_TOKEN_NAME, refreshToken.getToken(), (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); + return accessTokenProvider.createToken(member); + } - private void clearRefreshTokenCookie() { - HttpResponseUtil.addCookie(AuthConstants.REFRESH_TOKEN_HEADER_NAME, "", 0); + /** + * Functional interface for repository operations. + * Enables flexible repository operation handling. + */ + @FunctionalInterface + private interface RepositoryOperation { + void execute(RefreshToken refreshToken); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java index c51fd8b0..ddf8cc27 100644 --- a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java @@ -14,6 +14,8 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -25,13 +27,13 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthorizationFilter jwtAuthenticationFilter; + private final JwtAuthorizationFilter jwtAuthorizationFilter; private final JwtExceptionFilter jwtExceptionFilter; // 완전 공개 엔드 포인트 (우선순위 최상) private static final String[] PUBLIC_ENDPOINTS = { "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", - "/v1/api/auth/login/**", "/v1/api/auth/sign-up" + "/v1/api/auth/refresh", "/v1/api/auth/login/**" }; // 관리자 전용 엔드포인트 @@ -64,8 +66,13 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http - // Disable unnecessary features for stateless API - .csrf(AbstractHttpConfigurer::disable) + + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) + .requireCsrfProtectionMatcher(request -> request.getServletPath() + .equals("/v1/api/auth/refresh"))) + .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement(session -> session @@ -82,7 +89,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(this::configureAuthorization) // Add custom filters - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) .build(); @@ -121,6 +128,7 @@ public UrlBasedCorsConfigurationSource corsConfigurationSource() { config.addAllowedMethod("*"); config.setAllowedOrigins(List.of(ALLOWED_ORIGINS)); config.addExposedHeader(HttpHeaders.AUTHORIZATION); + config.addExposedHeader("X-XSRF-TOKEN"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 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 b92bddd6..debd0509 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -9,15 +9,16 @@ public class AuthConstants { public static final String TOKEN_PREFIX = "Bearer "; - public static final String REFRESH_TOKEN_HEADER_NAME = "Refresh-Token"; + public static final String REFRESH_TOKEN_NAME = "refresh-token"; - 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); + public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(15); + public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(15); + public static final Duration SIGN_UP_TOKEN_DURATION = Duration.ofMinutes(15); - // Redis Prefix + public static final Duration SOCIAL_LINK_DURATION = Duration.ofMinutes(20); + + // Redis Prefixt 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/SignUpMemberRequest.java b/src/main/java/com/juu/juulabel/common/dto/request/SignUpMemberRequest.java index 2af8f609..8860e7f0 100644 --- a/src/main/java/com/juu/juulabel/common/dto/request/SignUpMemberRequest.java +++ b/src/main/java/com/juu/juulabel/common/dto/request/SignUpMemberRequest.java @@ -1,9 +1,7 @@ package com.juu.juulabel.common.dto.request; import com.juu.juulabel.member.domain.Gender; -import com.juu.juulabel.member.domain.Provider; import com.juu.juulabel.terms.request.TermsAgreement; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -11,18 +9,8 @@ import java.util.List; public record SignUpMemberRequest( - @Email(message = "이메일 주소 형식이 잘못되었습니다.") - String email, - @NotBlank(message = "닉네임이 누락되었습니다.") - String nickname, - @NotNull(message = "성별이 누락되었습니다.") - Gender gender, - @NotNull(message = "가입 경로가 누락되었습니다.") - Provider provider, - @NotNull(message = "공급 고유 번호가 누락되었습니다.") - String providerId, - @NotEmpty(message = "주종 선택값이 누락되었습니다.") - List alcoholTypeIds, - List termsAgreements -) { + @NotBlank(message = "닉네임이 누락되었습니다.") String nickname, + @NotNull(message = "성별이 누락되었습니다.") Gender gender, + @NotEmpty(message = "주종 선택값이 누락되었습니다.") List alcoholTypeIds, + List termsAgreements) { } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java index 15decaea..e60d046e 100644 --- a/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java +++ b/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java @@ -1,11 +1,7 @@ package com.juu.juulabel.common.dto.response; -import com.juu.juulabel.member.request.OAuthUserInfo; -import com.juu.juulabel.member.token.Token; - public record LoginResponse( - Token token, - boolean isNewMember, - OAuthUserInfo oAuthUserInfo -) { + String accessToken, + String signUpToken, + String email) { } diff --git a/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java index 3f59db02..6b71e9c4 100644 --- a/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java +++ b/src/main/java/com/juu/juulabel/common/dto/response/SignUpMemberResponse.java @@ -1,9 +1,6 @@ package com.juu.juulabel.common.dto.response; -import com.juu.juulabel.member.token.Token; - public record SignUpMemberResponse( - Long memberId, - Token token -) { + Long memberId, + String accessToken) { } \ No newline at end of file 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 e842dc02..0cc07045 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 @@ -34,8 +34,8 @@ public enum ErrorCode { /** * Authorization */ - DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "Device-Id 헤더가 필요합니다."), - OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "Provider를 찾을 수 없습니다."), + DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "헤더에 Device-Id가 누락되었습니다."), + OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 로그인 경로를 찾을 수 없습니다."), /** * AuthException 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 8935abd7..87a54a31 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 @@ -4,9 +4,12 @@ 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.constants.AuthConstants; import com.juu.juulabel.common.exception.AuthException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.response.CommonResponse; +import com.juu.juulabel.common.util.CookieUtil; + import io.sentry.Sentry; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -41,6 +44,7 @@ public ResponseEntity> handle(InvalidParamException e) { public ResponseEntity> handle(AuthException e) { log.error("AuthException :", e); Sentry.captureException(e); + CookieUtil.removeCookie(AuthConstants.REFRESH_TOKEN_NAME); return CommonResponse.fail(e.getErrorCode(), 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 f8e9779e..ea65ed09 100644 --- a/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java +++ b/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java @@ -7,20 +7,23 @@ 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; @Component -@RequiredArgsConstructor public class OAuthProviderFactory { private final KakaoProvider kakaoProvider; private final GoogleProvider googleProvider; + public OAuthProviderFactory(KakaoProvider kakaoProvider, + GoogleProvider googleProvider) { + this.kakaoProvider = kakaoProvider; + this.googleProvider = googleProvider; + } + private OAuthProvider getOAuthProvider(Provider provider) { return switch (provider) { case KAKAO -> kakaoProvider; @@ -29,18 +32,18 @@ private OAuthProvider getOAuthProvider(Provider provider) { }; } - public OAuthUser getOAuthUser(OAuthLoginRequest oAuthLoginRequest) { - Provider provider = oAuthLoginRequest.provider(); - String accessToken = getOAuthToken( - provider, - oAuthLoginRequest.redirectUri(), - oAuthLoginRequest.code()).accessToken(); + public OAuthUser getOAuthUser(OAuthLoginRequest request) { - return getOAuthProvider(provider).getOAuthUser(accessToken); - } + System.out.println("request.redirectUri() = " + request.redirectUri()); + System.out.println("request.code() = " + request.code()); - private OAuthToken getOAuthToken(Provider provider, String redirectUri, String code) { - return getOAuthProvider(provider).getOAuthToken(redirectUri, code); - } + Provider provider = request.provider(); + String accessToken = getOAuthProvider(provider) + .getOAuthToken(request.redirectUri(), request.code()) + .accessToken(); + return getOAuthProvider(provider) + .getOAuthUser(accessToken); + + } } diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java index ab5ba481..40824261 100644 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java @@ -1,17 +1,21 @@ package com.juu.juulabel.common.filter; -import com.juu.juulabel.common.provider.JwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.CustomJwtException; import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.jwt.AccessTokenProvider; +import com.juu.juulabel.common.provider.jwt.SignupTokenProvider; import com.juu.juulabel.common.response.CommonResponse; -import com.juu.juulabel.common.util.AuthorizationExtractor; +import com.juu.juulabel.common.util.HttpRequestUtil; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.MalformedJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; @@ -21,43 +25,126 @@ import java.io.IOException; +@Slf4j @Component @RequiredArgsConstructor public class JwtAuthorizationFilter extends OncePerRequestFilter { - private final JwtTokenProvider jwtTokenProvider; + private final AccessTokenProvider accessTokenProvider; + private final SignupTokenProvider signUpTokenProvider; + private final ObjectMapper objectMapper; + + // Cache frequently used paths for better performance + private static final String SIGNUP_PATH_PREFIX = "/v1/api/auth/sign-up"; + private static final String UTF_8 = "UTF-8"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String header = AuthorizationExtractor.getAuthorization(); - if (header != null) { - String token = jwtTokenProvider.resolveToken(header); - try { - if (jwtTokenProvider.isValidateToken(token)) { - authenticate(token); + try { + String authHeader = extractAuthorizationHeader(request); + + if (authHeader != null) { + if (isSignUpRequest()) { + processSignUpToken(authHeader); + } else { + processAccessToken(authHeader); } - } catch (ExpiredJwtException | MalformedJwtException e) { - handleJwtException(response); - return; + } else if (isSignUpRequest()) { + // Sign-up requests require authentication + throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); } + // For other requests without auth header, let Spring Security handle + // authorization + + } catch (CustomJwtException e) { + handleJwtException(response, e); + return; + } catch (AuthException e) { + handleAuthException(response, e); + return; + } catch (Exception e) { + log.error("Unexpected error in JWT filter", e); + handleUnexpectedException(response); + return; } filterChain.doFilter(request, response); } - private void authenticate(String token) { - Authentication authentication = jwtTokenProvider.getAuthentication(token); + /** + * Extract Authorization header directly from request for better performance + */ + private String extractAuthorizationHeader(HttpServletRequest request) { + return request.getHeader(HttpHeaders.AUTHORIZATION); + } + + /** + * Check if the request is a sign-up request using the available request + * parameter + */ + private boolean isSignUpRequest() { + return HttpRequestUtil.isPathMatch(SIGNUP_PATH_PREFIX); + } + + /** + * Process sign-up token with validation + */ + private void processSignUpToken(String authHeader) { + try { + String token = signUpTokenProvider.resolveToken(authHeader); + + Authentication authentication = signUpTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (Exception e) { + log.error("Unexpected error in sign-up token processing", e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + /** + * Process access token with validation + */ + private void processAccessToken(String authHeader) { + String token = accessTokenProvider.resolveToken(authHeader); + Authentication authentication = accessTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); } - private void handleJwtException(HttpServletResponse response) throws IOException { - response.setCharacterEncoding("UTF-8"); - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter() - .write(CommonResponse.fail(ErrorCode.INVALID_AUTHENTICATION, "만료되었거나 잘못된 토큰입니다.").toString()); + /** + * Handle JWT-specific exceptions with appropriate error codes + */ + private void handleJwtException(HttpServletResponse response, CustomJwtException e) throws IOException { + writeErrorResponse(response, HttpStatus.UNAUTHORIZED, + CommonResponse.fail(e.getErrorCode(), e.getMessage()).getBody()); + } + + /** + * Handle authentication exceptions + */ + private void handleAuthException(HttpServletResponse response, AuthException e) throws IOException { + writeErrorResponse(response, HttpStatus.UNAUTHORIZED, + CommonResponse.fail(e.getErrorCode(), e.getMessage()).getBody()); } + /** + * Handle unexpected exceptions + */ + private void handleUnexpectedException(HttpServletResponse response) throws IOException { + writeErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, + CommonResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR, "인증 처리 중 오류가 발생했습니다.").getBody()); + } + + /** + * Write error response with proper JSON serialization + */ + private void writeErrorResponse(HttpServletResponse response, HttpStatus status, CommonResponse errorResponse) + throws IOException { + response.setCharacterEncoding(UTF_8); + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java b/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java new file mode 100644 index 00000000..fc5aeb49 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java @@ -0,0 +1,16 @@ +package com.juu.juulabel.common.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "app.cookie") +public class CookieProperties { + private boolean secure; + + public boolean isSecure() { + return secure; + } +} diff --git a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java deleted file mode 100644 index 71164da7..00000000 --- a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.juu.juulabel.common.provider; - -import com.juu.juulabel.common.exception.CustomJwtException; -import com.juu.juulabel.common.exception.InvalidParamException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.MemberRole; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; -import io.jsonwebtoken.security.SignatureException; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import static com.juu.juulabel.common.constants.AuthConstants.*; - -import javax.crypto.SecretKey; -import java.time.Duration; -import java.util.*; -import java.util.function.Function; - -@Component -public class JwtTokenProvider { - private static final String ISSUER = "juulabel"; - private static final String ROLE_CLAIM = "role"; - - private final SecretKey key; - private final JwtParser jwtParser; - - public JwtTokenProvider(@Value("${spring.jwt.secret}") String key) { - this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(key)); - this.jwtParser = Jwts.parser().verifyWith(this.key).build(); - } - - public String createAccessToken(Member member) { - return buildToken(member.getId(), member.getRole().name(), ACCESS_TOKEN_DURATION); - } - - public String createRefreshToken(Member member) { - return buildToken(member.getId(), member.getRole().name(), REFRESH_TOKEN_DURATION); - } - - private String buildToken(Long memberId, String role, Duration duration) { - Date now = new Date(); - Date expirationDate = new Date(now.getTime() + duration.toMillis()); - - JwtBuilder builder = Jwts.builder() - .subject(String.valueOf(memberId)) - .issuedAt(now) - .issuer(ISSUER) - .expiration(expirationDate) - .signWith(key); - - if (role != null) { - builder.claim(ROLE_CLAIM, role); - } - - return builder.compact(); - } - - public Authentication getAuthentication(String accessToken) { - return extractFromClaims(accessToken, claims -> { - String role = claims.get(ROLE_CLAIM, String.class); - Long memberId = Long.parseLong(claims.getSubject()); - - Member member = Member.builder() - .id(memberId) - .role(MemberRole.valueOf(role)) - .build(); - - return new UsernamePasswordAuthenticationToken( - member, - null, - Collections.singletonList(new SimpleGrantedAuthority(role))); - }); - } - - public Member getMemberFromToken(String token) { - return extractFromClaims(token, claims -> { - Long memberId = Long.parseLong(claims.getSubject()); - String role = claims.get(ROLE_CLAIM, String.class); - - return Member.builder() - .id(memberId) - .role(role != null ? MemberRole.valueOf(role) : MemberRole.ROLE_USER) - .build(); - }); - } - - public String resolveToken(String header) { - if (!StringUtils.hasText(header)) { - throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); - } - return header.replace(TOKEN_PREFIX, ""); - } - - public boolean isValidateToken(String token) { - if (!StringUtils.hasText(token)) { - return false; - } - try { - return !parseClaims(token).getExpiration().before(new Date()); - } catch (CustomJwtException e) { - return false; - } - } - - public Date getExpirationByToken(String token) { - return extractFromClaims(token, Claims::getExpiration); - } - - private T extractFromClaims(String token, Function claimsResolver) { - return claimsResolver.apply(parseClaims(token)); - } - - private Claims parseClaims(String token) { - try { - return jwtParser.parseSignedClaims(token).getPayload(); - } catch (SignatureException | MalformedJwtException ex) { - throw new CustomJwtException(ErrorCode.JWT_MALFORMED_EXCEPTION); - } catch (ExpiredJwtException ex) { - throw new CustomJwtException(ErrorCode.JWT_EXPIRED_EXCEPTION); - } catch (UnsupportedJwtException ex) { - throw new CustomJwtException(ErrorCode.JWT_UNSUPPORTED_EXCEPTION); - } catch (IllegalArgumentException ex) { - throw new CustomJwtException(ErrorCode.JWT_ILLEGAL_ARGUMENT_EXCEPTION); - } - } - -} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java new file mode 100644 index 00000000..b694d436 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java @@ -0,0 +1,49 @@ +package com.juu.juulabel.common.provider.jwt; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Collections; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberRole; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +@Component +public class AccessTokenProvider extends MemberTokenProvider { + + public AccessTokenProvider(@Value("${spring.jwt.access-key}") String secretKey) { + super(secretKey); + } + + public String createToken(Member member) { + return this.createToken(member, AuthConstants.ACCESS_TOKEN_DURATION); + } + + @Override + public String createToken(Member member, Duration duration) { + return super.createToken(member, duration); + } + + public Authentication getAuthentication(String accessToken) { + return extractFromClaims(accessToken, claims -> { + String role = claims.get(ROLE_CLAIM, String.class); + Long memberId = Long.parseLong(claims.getSubject()); + + Member member = Member.builder() + .id(memberId) + .role(MemberRole.valueOf(role)) + .build(); + + return new UsernamePasswordAuthenticationToken( + member, + null, + Collections.singletonList(new SimpleGrantedAuthority(role))); + }); + } +} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..11b63249 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java @@ -0,0 +1,58 @@ +package com.juu.juulabel.common.provider.jwt; + +import com.juu.juulabel.common.exception.CustomJwtException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.constants.AuthConstants; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.SecretKey; +import java.util.*; +import java.util.function.Function; + +@Component +public abstract class JwtTokenProvider { + protected static final String ISSUER = "juulabel"; + protected static final String ROLE_CLAIM = "role"; + + protected final SecretKey key; + protected final JwtParser jwtParser; + + protected JwtTokenProvider(String secretKey) { + this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)); + this.jwtParser = Jwts.parser().verifyWith(this.key).build(); + } + + public String resolveToken(String header) { + if (!StringUtils.hasText(header)) { + throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); + } + return header.replace(AuthConstants.TOKEN_PREFIX, ""); + } + + protected T extractFromClaims(String token, Function claimsResolver) { + return claimsResolver.apply(parseClaims(token)); + } + + protected Claims parseClaims(String token) { + try { + return jwtParser.parseSignedClaims(token).getPayload(); + } catch (SignatureException | MalformedJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_MALFORMED_EXCEPTION); + } catch (ExpiredJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_EXPIRED_EXCEPTION); + } catch (UnsupportedJwtException ex) { + throw new CustomJwtException(ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } catch (IllegalArgumentException ex) { + throw new CustomJwtException(ErrorCode.JWT_ILLEGAL_ARGUMENT_EXCEPTION); + } + } + +} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java new file mode 100644 index 00000000..3f631683 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java @@ -0,0 +1,39 @@ +package com.juu.juulabel.common.provider.jwt; + +import java.time.Duration; +import java.util.Date; + +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberRole; + +import io.jsonwebtoken.Jwts; + +public abstract class MemberTokenProvider extends JwtTokenProvider { + + protected MemberTokenProvider(String secretKey) { + super(secretKey); + } + + public String createToken(Member member, Duration duration) { + return Jwts.builder() + .subject(String.valueOf(member.getId())) + .claim(ROLE_CLAIM, member.getRole().name()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + duration.toMillis())) + .signWith(key) + .compact(); + } + + public Member getMemberFromToken(String token) { + return extractFromClaims(token, claims -> { + Long memberId = Long.parseLong(claims.getSubject()); + String role = claims.get(ROLE_CLAIM, String.class); + + return Member.builder() + .id(memberId) + .role(role != null ? MemberRole.valueOf(role) : MemberRole.ROLE_USER) + .build(); + }); + } + +} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java new file mode 100644 index 00000000..38fba8d8 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java @@ -0,0 +1,35 @@ +package com.juu.juulabel.common.provider.jwt; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.util.HashingUtil; +import com.juu.juulabel.common.util.HttpRequestUtil; +import com.juu.juulabel.common.util.IpAddressExtractor; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.auth.domain.ClientId; +import com.juu.juulabel.auth.domain.RefreshToken; + +@Component +public class RefreshTokenProvider extends MemberTokenProvider { + + public RefreshTokenProvider(@Value("${spring.jwt.refresh-key}") String secretKey) { + super(secretKey); + } + + public RefreshToken buildRefreshToken(Member member) { + String token = createToken(member, AuthConstants.REFRESH_TOKEN_DURATION); + String hashedToken = HashingUtil.hashSha256(token); + + return RefreshToken.builder() + .token(token) + .hashedToken(hashedToken) + .memberId(member.getId()) + .clientId(ClientId.WEB) + .deviceId(HttpRequestUtil.getDeviceId()) + .ipAddress(IpAddressExtractor.getClientIpAddress()) + .userAgent(HttpRequestUtil.getUserAgent()) + .build(); + } +} diff --git a/src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java new file mode 100644 index 00000000..427d92ad --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java @@ -0,0 +1,91 @@ +package com.juu.juulabel.common.provider.jwt; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +import com.juu.juulabel.auth.domain.SignUpToken; +import com.juu.juulabel.auth.service.SocialLinkService; +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; + +@Component +public class SignupTokenProvider extends JwtTokenProvider { + + private final SocialLinkService socialLinkService; + + public SignupTokenProvider(@Value("${spring.jwt.signup-key}") String secretKey, + SocialLinkService socialLinkService) { + super(secretKey); + this.socialLinkService = socialLinkService; + } + + public String createToken(OAuthUser oAuthUser, String nonce) { + String email = oAuthUser.email(); + Provider provider = oAuthUser.provider(); + String providerId = oAuthUser.id(); + Map claims = new HashMap<>(); + claims.put("email", email); + claims.put("provider", provider.name()); + claims.put("providerId", providerId); + claims.put("nonce", nonce); + claims.put("aud", "user-signup-completion"); + return Jwts.builder() + .claims(claims) + .issuedAt(new Date()) + .issuer(ISSUER) + .expiration(new Date(System.currentTimeMillis() + AuthConstants.SIGN_UP_TOKEN_DURATION.toMillis())) + .signWith(key) + .compact(); + } + + public Authentication getAuthentication(String token) { + + return extractFromClaims(token, claims -> { + SignUpToken signUpToken = buildSignUpToken(token); + socialLinkService.verify(signUpToken); + + return new UsernamePasswordAuthenticationToken(signUpToken, null, + Collections.emptyList()); + }); + } + + public SignUpToken buildSignUpToken(String token) { + Claims claims = parseClaims(token); + String email = getClaimAsString(claims, "email"); + Provider provider = Provider.valueOf(getClaimAsString(claims, "provider")); + String providerId = getClaimAsString(claims, "providerId"); + String nonce = getClaimAsString(claims, "nonce"); + String aud = getClaimAsString(claims, "aud"); + + if (!"[user-signup-completion]".equals(aud)) { + throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); + } + + return new SignUpToken(token, email, provider, providerId, nonce); + } + + /** + * Safely extract claim as string, handling potential collection types + */ + private String getClaimAsString(Claims claims, String claimName) { + Object claimValue = claims.get(claimName); + if (claimValue == null) { + return null; + } + return claimValue.toString(); + } + +} diff --git a/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java b/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java index df8eba8f..1db32c86 100644 --- a/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java +++ b/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java @@ -17,11 +17,7 @@ */ public abstract class AbstractHttpUtil { - /** - * Protected constructor to prevent direct instantiation - */ protected AbstractHttpUtil() { - throw new AssertionError("Utility class should not be instantiated"); } /** diff --git a/src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java b/src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java deleted file mode 100644 index bcb345c4..00000000 --- a/src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.juu.juulabel.common.util; - -import org.springframework.http.HttpHeaders; -import jakarta.servlet.http.HttpServletRequest; - -/** - * Utility class for authorization header extraction - */ -public final class AuthorizationExtractor extends AbstractHttpUtil { - - /** - * Private constructor to prevent instantiation - */ - private AuthorizationExtractor() { - super(); - } - - /** - * Extract authorization header from request - * - * @return authorization header value - */ - public static String getAuthorization() { - HttpServletRequest request = getCurrentRequest(); - return request.getHeader(HttpHeaders.AUTHORIZATION); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/CookieUtil.java b/src/main/java/com/juu/juulabel/common/util/CookieUtil.java new file mode 100644 index 00000000..212942b6 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/CookieUtil.java @@ -0,0 +1,69 @@ +package com.juu.juulabel.common.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Utility class for cookie management operations + */ + +public final class CookieUtil extends AbstractHttpUtil { + + private CookieUtil() { + // Private constructor to prevent instantiation + } + + public static String getCookie(String name) { + HttpServletRequest request = getCurrentRequest(); + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return cookie.getValue(); + } + } + } + return null; + } + + /** + * Adds a secure HTTP-only cookie to the response + * + * @param name the cookie name + * @param value the cookie value + * @param maxAge the cookie max age in seconds + */ + public static void addCookie(String name, String value, int maxAge) { + HttpServletResponse response = getCurrentResponse(); + Cookie cookie = createSecureCookie(name, value, maxAge); + response.addCookie(cookie); + } + + /** + * Removes a cookie by setting its max age to 0 + * + * @param name the cookie name to remove + */ + public static void removeCookie(String name) { + addCookie(name, "", 0); + } + + /** + * Creates a secure cookie with default security settings + * + * @param name the cookie name + * @param value the cookie value + * @param maxAge the cookie max age in seconds + * @return a configured secure cookie + */ + private static Cookie createSecureCookie(String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setMaxAge(maxAge); + cookie.setPath("/"); + cookie.setHttpOnly(true); + // TODO: 추후 환경에 따라 설정 변경 필요 + // cookie.setSecure(true); + return cookie; + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/DeviceIdExtractor.java b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java similarity index 54% rename from src/main/java/com/juu/juulabel/common/util/DeviceIdExtractor.java rename to src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java index 3f6cb9d3..34de9809 100644 --- a/src/main/java/com/juu/juulabel/common/util/DeviceIdExtractor.java +++ b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java @@ -1,23 +1,29 @@ package com.juu.juulabel.common.util; +import org.springframework.http.HttpHeaders; + import com.juu.juulabel.common.exception.BaseException; import com.juu.juulabel.common.exception.code.ErrorCode; + import jakarta.servlet.http.HttpServletRequest; -/** - * Utility class for device ID extraction - */ -public final class DeviceIdExtractor extends AbstractHttpUtil { +public class HttpRequestUtil extends AbstractHttpUtil { private static final String DEVICE_ID_HEADER_NAME = "Device-Id"; - /** - * Private constructor to prevent instantiation - */ - private DeviceIdExtractor() { + private HttpRequestUtil() { super(); } + public static boolean isPathMatch(String path) { + return getCurrentRequest().getRequestURI().startsWith(path); + } + + public static String getAuthorization() { + HttpServletRequest request = getCurrentRequest(); + return request.getHeader(HttpHeaders.AUTHORIZATION); + } + /** * Extract device ID from request headers * @@ -32,4 +38,15 @@ public static String getDeviceId() { } return deviceId.trim(); } -} \ No newline at end of file + + /** + * Extract user agent from request headers + * + * @return user agent string from User-Agent header + */ + + public static String getUserAgent() { + HttpServletRequest request = getCurrentRequest(); + return request.getHeader(HttpHeaders.USER_AGENT); + } +} diff --git a/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java deleted file mode 100644 index a66ec0e8..00000000 --- a/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.juu.juulabel.common.util; - -import org.springframework.web.context.request.ServletRequestAttributes; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; - -public final class HttpResponseUtil extends AbstractHttpUtil { - - private HttpResponseUtil() { - super(); - } - - /** - * Adds a secure HTTP-only cookie to the response - */ - public static void addCookie(String name, String value, int maxAge) { - HttpServletResponse response = getCurrentResponse(); - Cookie cookie = createSecureCookie(name, value, maxAge); - response.addCookie(cookie); - } - - /** - * Removes a cookie by setting its max age to 0 - */ - public static void removeCookie(String name) { - addCookie(name, "", 0); - } - - /** - * Creates a secure cookie with default settings - */ - private static Cookie createSecureCookie(String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setMaxAge(maxAge); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setSecure(true); - return cookie; - } - - public static HttpServletResponse getCurrentResponse() { - return getFromRequestAttributes(ServletRequestAttributes::getResponse); - } - -} diff --git a/src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java b/src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java deleted file mode 100644 index e9acf5fc..00000000 --- a/src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.juu.juulabel.common.util; - -import org.springframework.http.HttpHeaders; -import jakarta.servlet.http.HttpServletRequest; - -/** - * Utility class for user agent extraction - */ -public final class UserAgentExtractor extends AbstractHttpUtil { - - /** - * Private constructor to prevent instantiation - */ - private UserAgentExtractor() { - super(); - } - - /** - * Extract user agent from request headers - * - * @return user agent string from User-Agent header - */ - public static String getUserAgent() { - HttpServletRequest request = getCurrentRequest(); - return request.getHeader(HttpHeaders.USER_AGENT); - } -} \ No newline at end of file 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 881df209..dc7e71e5 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Member.java +++ b/src/main/java/com/juu/juulabel/member/domain/Member.java @@ -5,6 +5,8 @@ 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.member.request.OAuthUser; +import com.juu.juulabel.auth.domain.SignUpToken; import com.juu.juulabel.common.base.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -75,13 +77,13 @@ public class Member extends BaseTimeEntity { @Column(name = "deleted_at", columnDefinition = "datetime comment '탈퇴 일시'") private LocalDateTime deletedAt; - public static Member create(SignUpMemberRequest signUpMemberRequest) { + public static Member create(SignUpMemberRequest signUpMemberRequest, SignUpToken signUpToken) { return Member.builder() - .email(signUpMemberRequest.email()) + .email(signUpToken.email()) .nickname(signUpMemberRequest.nickname()) .gender(signUpMemberRequest.gender()) - .provider(signUpMemberRequest.provider()) - .providerId(signUpMemberRequest.providerId()) + .provider(signUpToken.provider()) + .providerId(signUpToken.providerId()) .status(MemberStatus.ACTIVE) .hasBadge(false) .role(MemberRole.ROLE_USER) @@ -104,14 +106,14 @@ public boolean isSameUser(Member other) { return this.equals(other); } - public void validateLoginMember(Provider provider, String providerId) { + public void validateLoginMember(OAuthUser oAuthUser) { if (this.deletedAt != null) { throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); } - if (!this.provider.equals(provider)) { + if (!this.provider.equals(oAuthUser.provider())) { throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); } - if (!this.providerId.equals(providerId)) { + if (!this.providerId.equals(oAuthUser.id())) { throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); } } diff --git a/src/main/java/com/juu/juulabel/member/domain/Provider.java b/src/main/java/com/juu/juulabel/member/domain/Provider.java index b9b61f95..d92b9e19 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Provider.java +++ b/src/main/java/com/juu/juulabel/member/domain/Provider.java @@ -1,7 +1,6 @@ package com.juu.juulabel.member.domain; public enum Provider { - - GOOGLE, KAKAO, NAVER, APPLE, EMAIL - + GOOGLE, + KAKAO; } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/request/GoogleUser.java b/src/main/java/com/juu/juulabel/member/request/GoogleUser.java index 5abba5c8..162fef2c 100644 --- a/src/main/java/com/juu/juulabel/member/request/GoogleUser.java +++ b/src/main/java/com/juu/juulabel/member/request/GoogleUser.java @@ -1,6 +1,7 @@ package com.juu.juulabel.member.request; import com.fasterxml.jackson.annotation.JsonProperty; +import com.juu.juulabel.member.domain.Provider; public record GoogleUser( @JsonProperty("id") String id, @@ -9,4 +10,9 @@ public record GoogleUser( @JsonProperty("picture") String picture ) implements OAuthUser{ + @Override + public Provider provider() { + return Provider.GOOGLE; + } + } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/request/KakaoUser.java b/src/main/java/com/juu/juulabel/member/request/KakaoUser.java index c12a22d5..e4f854eb 100644 --- a/src/main/java/com/juu/juulabel/member/request/KakaoUser.java +++ b/src/main/java/com/juu/juulabel/member/request/KakaoUser.java @@ -1,6 +1,7 @@ package com.juu.juulabel.member.request; import com.fasterxml.jackson.annotation.JsonProperty; +import com.juu.juulabel.member.domain.Provider; public record KakaoUser( @JsonProperty("id") String id, @@ -14,4 +15,9 @@ public String email() { return kakaoAccount.email(); } + @Override + public Provider provider() { + return Provider.KAKAO; + } + } diff --git a/src/main/java/com/juu/juulabel/member/request/OAuthUser.java b/src/main/java/com/juu/juulabel/member/request/OAuthUser.java index 7df6d408..b60361a1 100644 --- a/src/main/java/com/juu/juulabel/member/request/OAuthUser.java +++ b/src/main/java/com/juu/juulabel/member/request/OAuthUser.java @@ -1,6 +1,9 @@ package com.juu.juulabel.member.request; +import com.juu.juulabel.member.domain.Provider; + public interface OAuthUser { String id(); String email(); + Provider provider(); } diff --git a/src/main/java/com/juu/juulabel/redis/RedisScriptExecutor.java b/src/main/java/com/juu/juulabel/redis/RedisScriptExecutor.java index dd352c9f..1ea16edd 100644 --- a/src/main/java/com/juu/juulabel/redis/RedisScriptExecutor.java +++ b/src/main/java/com/juu/juulabel/redis/RedisScriptExecutor.java @@ -2,10 +2,8 @@ import org.springframework.data.redis.RedisSystemException; -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 io.lettuce.core.RedisCommandExecutionException; @@ -28,7 +26,6 @@ default void handleRedisException(RedisSystemException e) { } default void handleRedisScriptError(String errorMessage) { - HttpResponseUtil.addCookie(AuthConstants.REFRESH_TOKEN_HEADER_NAME, "", 0); - throw new BaseException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); + throw new AuthException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); } } \ No newline at end of file From ff10f8b647655e8a6bc754fd6b55c4bcc2384444 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Sat, 31 May 2025 20:58:05 +0900 Subject: [PATCH 4/5] Refactor SocialLinkService by removing unused imports and redundant code. Ensure proper formatting and maintainability in the authentication service. Additionally, add a newline at the end of the Member class for consistency. --- .../auth/service/SocialLinkService.java | 50 +------------------ .../juu/juulabel/member/domain/Member.java | 2 +- 2 files changed, 2 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java b/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java index ad942062..6261321b 100644 --- a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java +++ b/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java @@ -44,52 +44,4 @@ public void verify(SignUpToken signUpToken) { socialLink.markAsUsed(); socialLinkRepository.save(socialLink); } -} - -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); - } -} +} \ No newline at end of file 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 68a62bb8..dc7e71e5 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Member.java +++ b/src/main/java/com/juu/juulabel/member/domain/Member.java @@ -136,4 +136,4 @@ public int hashCode() { public void assignBadge() { this.hasBadge = true; } -} \ No newline at end of file +} From be27ffa4a3386d1b9d49deae50555bb18d4505ff Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Sun, 1 Jun 2025 11:18:32 +0900 Subject: [PATCH 5/5] Enhance token management and cookie handling in authentication service - Introduced CookieProperties to manage cookie security settings. - Updated CookieUtil to support secure cookie creation. - Modified TokenService to utilize secure cookie settings for refresh tokens. - Changed refresh token name in AuthConstants to uppercase for consistency. - Added ROLE_GUEST to MemberRole enum for expanded role management. These changes aim to improve security and maintainability in the authentication flow. --- .../juu/juulabel/auth/service/TokenService.java | 6 ++++-- .../juulabel/common/constants/AuthConstants.java | 2 +- .../common/filter/JwtAuthorizationFilter.java | 14 +------------- .../com/juu/juulabel/common/util/CookieUtil.java | 13 +++++++------ .../com/juu/juulabel/member/domain/MemberRole.java | 2 +- 5 files changed, 14 insertions(+), 23 deletions(-) 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 108074e3..6929573f 100644 --- a/src/main/java/com/juu/juulabel/auth/service/TokenService.java +++ b/src/main/java/com/juu/juulabel/auth/service/TokenService.java @@ -4,6 +4,7 @@ import com.juu.juulabel.auth.domain.RefreshToken; import com.juu.juulabel.auth.repository.RefreshTokenRepository; import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.properties.CookieProperties; import com.juu.juulabel.common.provider.jwt.AccessTokenProvider; import com.juu.juulabel.common.provider.jwt.RefreshTokenProvider; import com.juu.juulabel.common.provider.jwt.SignupTokenProvider; @@ -31,6 +32,7 @@ public class TokenService { private final RefreshTokenProvider refreshTokenProvider; private final SignupTokenProvider signupTokenProvider; private final RefreshTokenRepository refreshTokenRepository; + private final CookieProperties cookieProperties; /** * Creates and sets tokens for member registration. @@ -78,7 +80,7 @@ public String rotate(String oldToken) { refreshTokenRepository.rotate(newRefreshToken, hashedOldToken); CookieUtil.addCookie(AuthConstants.REFRESH_TOKEN_NAME, newRefreshToken.getToken(), - (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); + (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds(), cookieProperties.isSecure()); return accessTokenProvider.createToken(member); } @@ -122,7 +124,7 @@ private String createAccessAndRefreshToken(Member member, RepositoryOperation re repositoryOperation.execute(refreshToken); CookieUtil.addCookie(AuthConstants.REFRESH_TOKEN_NAME, refreshToken.getToken(), - (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); + (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds(), cookieProperties.isSecure()); return accessTokenProvider.createToken(member); } 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 debd0509..fb3118a1 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -9,7 +9,7 @@ public class AuthConstants { public static final String TOKEN_PREFIX = "Bearer "; - public static final String REFRESH_TOKEN_NAME = "refresh-token"; + public static final String REFRESH_TOKEN_NAME = "REFRESH-TOKEN"; public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(15); public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(15); diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java index 40824261..0c20f9c4 100644 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java @@ -31,7 +31,7 @@ public class JwtAuthorizationFilter extends OncePerRequestFilter { private final AccessTokenProvider accessTokenProvider; - private final SignupTokenProvider signUpTokenProvider; + private final SignupTokenProvider signUpTokenProvider; private final ObjectMapper objectMapper; // Cache frequently used paths for better performance @@ -64,10 +64,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } catch (AuthException e) { handleAuthException(response, e); return; - } catch (Exception e) { - log.error("Unexpected error in JWT filter", e); - handleUnexpectedException(response); - return; } filterChain.doFilter(request, response); @@ -129,14 +125,6 @@ private void handleAuthException(HttpServletResponse response, AuthException e) CommonResponse.fail(e.getErrorCode(), e.getMessage()).getBody()); } - /** - * Handle unexpected exceptions - */ - private void handleUnexpectedException(HttpServletResponse response) throws IOException { - writeErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, - CommonResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR, "인증 처리 중 오류가 발생했습니다.").getBody()); - } - /** * Write error response with proper JSON serialization */ diff --git a/src/main/java/com/juu/juulabel/common/util/CookieUtil.java b/src/main/java/com/juu/juulabel/common/util/CookieUtil.java index 212942b6..9a3e1471 100644 --- a/src/main/java/com/juu/juulabel/common/util/CookieUtil.java +++ b/src/main/java/com/juu/juulabel/common/util/CookieUtil.java @@ -34,9 +34,9 @@ public static String getCookie(String name) { * @param value the cookie value * @param maxAge the cookie max age in seconds */ - public static void addCookie(String name, String value, int maxAge) { + public static void addCookie(String name, String value, int maxAge, boolean isSecure) { HttpServletResponse response = getCurrentResponse(); - Cookie cookie = createSecureCookie(name, value, maxAge); + Cookie cookie = createSecureCookie(name, value, maxAge, isSecure); response.addCookie(cookie); } @@ -46,7 +46,7 @@ public static void addCookie(String name, String value, int maxAge) { * @param name the cookie name to remove */ public static void removeCookie(String name) { - addCookie(name, "", 0); + addCookie(name, "", 0, false); } /** @@ -57,13 +57,14 @@ public static void removeCookie(String name) { * @param maxAge the cookie max age in seconds * @return a configured secure cookie */ - private static Cookie createSecureCookie(String name, String value, int maxAge) { + private static Cookie createSecureCookie(String name, String value, int maxAge, boolean isSecure) { Cookie cookie = new Cookie(name, value); cookie.setMaxAge(maxAge); cookie.setPath("/"); cookie.setHttpOnly(true); - // TODO: 추후 환경에 따라 설정 변경 필요 - // cookie.setSecure(true); + cookie.setSecure(isSecure); + // TODO: SameSite 설정 필요 (Strict, Lax, None) + // cookie.setAttribute("SameSite", "Strict"); return cookie; } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/domain/MemberRole.java b/src/main/java/com/juu/juulabel/member/domain/MemberRole.java index 07a9a447..c1210c84 100644 --- a/src/main/java/com/juu/juulabel/member/domain/MemberRole.java +++ b/src/main/java/com/juu/juulabel/member/domain/MemberRole.java @@ -1,5 +1,5 @@ package com.juu.juulabel.member.domain; public enum MemberRole { - ROLE_ADMIN, ROLE_USER + ROLE_ADMIN, ROLE_USER, ROLE_GUEST }