diff --git a/docs/pr/PR-141-refactor---auth.md b/docs/pr/PR-141-refactor---auth.md index 09a6de5..efb15a0 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 0000000..55c95fa --- /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 dda372b..a7754f2 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 c661b54..0aa8468 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 2bb0384..4765cd7 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 0000000..275b572 --- /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 4ef6de6..932ac67 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 e38f118..6993058 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); } } -} +} \ No newline at end of file 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 e6c1bfa..0e90e97 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 fafc856..6261321 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,28 +20,28 @@ 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); } -} +} \ No newline at end of file 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 c90088a..6929573 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,137 @@ 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.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; +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; + private final CookieProperties cookieProperties; /** - * 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(), cookieProperties.isSecure()); - 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); + + repositoryOperation.execute(refreshToken); + + CookieUtil.addCookie(AuthConstants.REFRESH_TOKEN_NAME, refreshToken.getToken(), + (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds(), cookieProperties.isSecure()); + return accessTokenProvider.createToken(member); - private void setRefreshTokenCookie(String token) { - HttpResponseUtil.addCookie( - AuthConstants.REFRESH_TOKEN_HEADER_NAME, - token, - (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); } - 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 c51fd8b..ddf8cc2 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 b92bddd..fb3118a 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 2af8f60..8860e7f 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 15decae..e60d046 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 3f59db0..6b71e9c 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 e842dc0..0b50a13 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,10 +32,11 @@ public enum ErrorCode { JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), /** + * Authorization * 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 @@ -48,8 +49,7 @@ public enum ErrorCode { PROVIDER_ID_MISMATCH(HttpStatus.FORBIDDEN, "소셜 아이디 불일치"), DEVICE_ID_MISMATCH(HttpStatus.FORBIDDEN, "Device-Id 불일치"), USER_AGENT_MISMATCH(HttpStatus.FORBIDDEN, "User-Agent 불일치"), - HIGH_SECURITY_RISK(HttpStatus.FORBIDDEN, "높은 보안 위협이 감지되었습니다."), - + 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 8935abd..e3367b1 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,15 +4,19 @@ 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; 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; @@ -41,6 +45,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()); } @@ -92,6 +97,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 f8e9779..b6a0b7e 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,15 @@ 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) { + + Provider provider = request.provider(); + String accessToken = getOAuthProvider(provider) + .getOAuthToken(request.redirectUri(), request.code()) + .accessToken(); - return getOAuthProvider(provider).getOAuthUser(accessToken); - } + return getOAuthProvider(provider) + .getOAuthUser(accessToken); - private OAuthToken getOAuthToken(Provider provider, String redirectUri, String code) { - return getOAuthProvider(provider).getOAuthToken(redirectUri, code); } - -} +} \ No newline at end of file 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 ab5ba48..0c20f9c 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,114 @@ 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; } 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()); + } + + /** + * 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 0000000..fc5aeb4 --- /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 71164da..0000000 --- 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 0000000..b694d43 --- /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 0000000..11b6324 --- /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 0000000..3f63168 --- /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 0000000..38fba8d --- /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 0000000..427d92a --- /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 df8eba8..1db32c8 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 bcb345c..0000000 --- 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 0000000..9a3e147 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/CookieUtil.java @@ -0,0 +1,70 @@ +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, boolean isSecure) { + HttpServletResponse response = getCurrentResponse(); + Cookie cookie = createSecureCookie(name, value, maxAge, isSecure); + 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, false); + } + + /** + * 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, boolean isSecure) { + Cookie cookie = new Cookie(name, value); + cookie.setMaxAge(maxAge); + cookie.setPath("/"); + cookie.setHttpOnly(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/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 3f6cb9d..34de980 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 a66ec0e..0000000 --- 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 e9acf5f..0000000 --- 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 881df20..dc7e71e 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/MemberRole.java b/src/main/java/com/juu/juulabel/member/domain/MemberRole.java index 07a9a44..c1210c8 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 } 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 b9b61f9..d92b9e1 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 5abba5c..162fef2 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 c12a22d..e4f854e 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 7df6d40..b60361a 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 dd352c9..1ea16ed 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