From 07c2d50f46fb8c3388e93c3062012da66a62d493 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Sun, 1 Jun 2025 15:33:59 +0900 Subject: [PATCH 1/7] Refactor JwtAuthorizationFilter to streamline authentication handling - Removed unnecessary sign-up request check, simplifying the authentication flow. - Enhanced error handling for invalid authentication scenarios. These changes aim to improve code clarity and maintainability in the authentication process. --- .../juu/juulabel/common/filter/JwtAuthorizationFilter.java | 5 +---- .../com/juu/juulabel/common/properties/CookieProperties.java | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) 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 0c20f9c..62eb660 100644 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java @@ -51,12 +51,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } else { processAccessToken(authHeader); } - } else if (isSignUpRequest()) { - // Sign-up requests require authentication + } else { throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); } - // For other requests without auth header, let Spring Security handle - // authorization } catch (CustomJwtException e) { handleJwtException(response, e); diff --git a/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java b/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java index 37cdf8d..27cdeb9 100644 --- a/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java +++ b/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java @@ -30,7 +30,7 @@ public class CookieProperties { * Default path for cookies. * Default: /app */ - private String path = "/app"; +private String path = "/app"; /** * Default SameSite attribute for secure cookies. From d7e30e43779e18eb7112e1ce5f7fa1f9177de6dc Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Tue, 3 Jun 2025 18:11:05 +0900 Subject: [PATCH 2/7] Add Paseto support and refactor authentication components - Introduced Paseto dependencies in build.gradle for enhanced token management. - Updated JuulabelApplication to enable configuration properties for better management. - Removed the TestAccessTokenController as it was no longer needed. - Refactored AuthController and AuthService to streamline logout functionality and improve error handling. - Updated error codes in ErrorCode for better clarity on authentication issues. - Enhanced Member domain to include new status and improved validation logic. These changes aim to strengthen security, optimize performance, and improve maintainability across the authentication system. --- build.gradle | 7 + docs/pr/PR-143-feat---apple-login.md | 512 ++++++++++++++++++ .../com/juu/juulabel/JuulabelApplication.java | 2 + .../admin/TestAccessTokenController.java | 28 - .../juulabel/auth/controller/AuthApiDocs.java | 39 +- .../auth/controller/AuthController.java | 36 +- .../juu/juulabel/auth/domain/ClientId.java | 27 - .../juulabel/auth/domain/RefreshToken.java | 62 --- .../juu/juulabel/auth/domain/SocialLink.java | 112 ---- .../LoginRefreshTokenScriptExecutor.java | 46 -- .../RevokeRefreshTokenByIndexKeyExecutor.java | 40 -- .../RotateRefreshTokenScriptExecutor.java | 64 --- .../SaveRefreshTokenScriptExecutor.java | 46 -- .../repository/RefreshTokenRepository.java | 33 -- .../RefreshTokenRepositoryImpl.java | 51 -- .../auth/repository/SocialLinkRepository.java | 9 - .../repository/UserSessionRepository.java | 10 + .../juulabel/auth/service/AuthService.java | 215 +++++--- .../auth/service/SocialLinkService.java | 47 -- .../juulabel/auth/service/TokenService.java | 139 ----- .../common/client/AppleAuthClient.java | 28 + .../common/client/GoogleAuthClient.java | 8 +- .../common/client/KakaoApiClient.java | 1 - .../common/client/KakaoAuthClient.java | 8 +- .../common/config/SecurityConfig.java | 44 +- .../common/constants/AuthConstants.java | 14 +- .../common/converter/ProviderConverter.java | 2 +- .../common/dto/request/OAuthLoginRequest.java | 1 + .../common/dto/response/LoginResponse.java | 7 - .../common/dto/response/RefreshResponse.java | 5 - .../dto/response/RelationSearchResponse.java | 5 +- .../dto/response/SignUpMemberResponse.java | 1 + .../exception/CustomPasetoException.java | 27 + .../common/exception/code/ErrorCode.java | 31 +- .../common/factory/OAuthProviderFactory.java | 26 +- .../common/filter/AuthExceptionFilter.java | 40 ++ ...onFilter.java => AuthorizationFilter.java} | 100 ++-- .../common/filter/JwtExceptionFilter.java | 48 -- .../handler/CustomAccessDeniedHandler.java | 63 +++ .../common/properties/CookieProperties.java | 4 +- .../common/properties/RedirectProperties.java | 38 ++ .../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/provider/oauth/AppleProvider.java | 49 ++ .../common/provider/oauth/GoogleProvider.java | 22 +- .../common/provider/oauth/KakaoProvider.java | 12 +- .../common/provider/oauth/OAuthProvider.java | 7 +- .../common/provider/token/TokenProvider.java | 31 ++ .../token/jwt/AppleTokenProvider.java | 154 ++++++ .../provider/token/jwt/JwtTokenProvider.java | 65 +++ .../token/paseto/PasetoTokenProvider.java | 69 +++ .../token/paseto/SignupTokenProvider.java | 128 +++++ .../juu/juulabel/common/util/HashingUtil.java | 23 - .../juulabel/common/util/HttpRequestUtil.java | 5 + .../common/util/HttpResponseUtil.java | 41 ++ .../common/util/SecurityResponseUtil.java | 57 ++ .../juu/juulabel/member/domain/Member.java | 29 +- .../juulabel/member/domain/MemberStatus.java | 2 +- .../juu/juulabel/member/domain/Provider.java | 3 +- .../member/repository/MemberReader.java | 2 + .../repository/jpa/MemberJpaRepository.java | 8 +- .../member/request/ApplePublicKey.java | 11 + .../juulabel/member/request/AppleUser.java | 15 + .../juulabel/member/request/KakaoUser.java | 3 +- .../member/request/OAuthUserInfo.java | 11 - .../juu/juulabel/member/token/AppleToken.java | 21 + .../juulabel/member/token/GoogleToken.java | 13 +- .../juu/juulabel/member/token/KakaoToken.java | 17 +- .../juu/juulabel/member/token/OAuthToken.java | 1 + .../domain => member/token}/SignUpToken.java | 2 +- .../com/juu/juulabel/member/token/Token.java | 9 - .../juulabel/member/token/UserSession.java | 68 +++ .../juu/juulabel/member/util/MemberUtils.java | 174 ++++-- .../juu/juulabel/redis/RedisScriptName.java | 6 - .../juu/juulabel/redis/SessionManager.java | 167 ++++++ .../resources/scripts/login_refresh_token.lua | 44 -- .../revoke_refresh_token_by_index_key.lua | 41 -- .../scripts/rotate_refresh_token.lua | 95 ---- .../resources/scripts/save_refresh_token.lua | 31 -- 82 files changed, 2066 insertions(+), 1668 deletions(-) create mode 100644 docs/pr/PR-143-feat---apple-login.md delete mode 100644 src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java delete mode 100644 src/main/java/com/juu/juulabel/auth/domain/ClientId.java delete mode 100644 src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java delete mode 100644 src/main/java/com/juu/juulabel/auth/domain/SocialLink.java delete mode 100644 src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java delete mode 100644 src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java delete mode 100644 src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java delete mode 100644 src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java delete mode 100644 src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java delete mode 100644 src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java delete mode 100644 src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java create mode 100644 src/main/java/com/juu/juulabel/auth/repository/UserSessionRepository.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/TokenService.java create mode 100644 src/main/java/com/juu/juulabel/common/client/AppleAuthClient.java delete mode 100644 src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java delete mode 100644 src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java create mode 100644 src/main/java/com/juu/juulabel/common/exception/CustomPasetoException.java create mode 100644 src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java rename src/main/java/com/juu/juulabel/common/filter/{JwtAuthorizationFilter.java => AuthorizationFilter.java} (50%) delete mode 100644 src/main/java/com/juu/juulabel/common/filter/JwtExceptionFilter.java create mode 100644 src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/juu/juulabel/common/properties/RedirectProperties.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/HashingUtil.java create mode 100644 src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java create mode 100644 src/main/java/com/juu/juulabel/common/util/SecurityResponseUtil.java create mode 100644 src/main/java/com/juu/juulabel/member/request/ApplePublicKey.java create mode 100644 src/main/java/com/juu/juulabel/member/request/AppleUser.java delete mode 100644 src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java create mode 100644 src/main/java/com/juu/juulabel/member/token/AppleToken.java rename src/main/java/com/juu/juulabel/{auth/domain => member/token}/SignUpToken.java (83%) delete mode 100644 src/main/java/com/juu/juulabel/member/token/Token.java create mode 100644 src/main/java/com/juu/juulabel/member/token/UserSession.java create mode 100644 src/main/java/com/juu/juulabel/redis/SessionManager.java delete mode 100644 src/main/resources/scripts/login_refresh_token.lua delete mode 100644 src/main/resources/scripts/revoke_refresh_token_by_index_key.lua delete mode 100644 src/main/resources/scripts/rotate_refresh_token.lua delete mode 100644 src/main/resources/scripts/save_refresh_token.lua diff --git a/build.gradle b/build.gradle index 08df474..7dcabe1 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,13 @@ dependencies { // jjwt implementation 'io.jsonwebtoken:jjwt:0.12.5' + // paseto + implementation 'dev.paseto:jpaseto-api:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-impl:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-jackson:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-bouncy-castle:0.7.0' + runtimeOnly 'dev.paseto:jpaseto-sodium:0.7.0' + // lombok implementation 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok' diff --git a/docs/pr/PR-143-feat---apple-login.md b/docs/pr/PR-143-feat---apple-login.md new file mode 100644 index 0000000..ac89110 --- /dev/null +++ b/docs/pr/PR-143-feat---apple-login.md @@ -0,0 +1,512 @@ +# 애플 로그인 추가 및 소셜 로그인 리팩토링 (PR [#143](https://github.com/juulabel/juulabel-back/pull/143)) + +## TL;DR + +- **Apple OAuth 로그인 지원 추가**: JWT 기반 Apple Sign In 구현 +- **OAuth 콜백 방식 변경**: 클라이언트→서버→클라이언트 흐름으로 개선 +- **세션 기반 인증 시스템 도입**: JWT Access/Refresh Token에서 Redis 세션 기반으로 전환 +- **PASETO 기반 회원가입 토큰**: JWT 대신 보안이 강화된 PASETO를 이용한 일회성 회원가입 토큰 구현 +- **HttpOnly 쿠키 보안 강화**: 모든 인증 토큰을 HttpOnly 쿠키로 전송하여 XSS 공격 방지 + +## 🎯 주요 변경사항 + +### 1. Apple OAuth 구현 + +- **JWT 토큰 검증**: Apple의 Public Key를 이용한 ID Token 검증 로직 구현 +- **RSA 암호화 지원**: Apple의 RSA 공개키를 통한 토큰 서명 검증 +- **Apple API 클라이언트**: FeignClient를 활용한 Apple OAuth 서버 연동 + +### 2. 소셜 로그인 아키텍처 개선 + +- **Factory Pattern 도입**: `OAuthProviderFactory`로 프로바이더별 인스턴스 관리 +- **Strategy Pattern 적용**: `OAuthProvider` 인터페이스를 통한 다형성 구현 +- **확장성 확보**: 새로운 소셜 로그인 추가 시 최소한의 코드 변경으로 지원 가능 + +### 3. OAuth 콜백 플로우 개선 + +**기존 방식 (클라이언트 직접 처리):** + +``` +OAuth Provider → 클라이언트 → 서버 API (인가코드 전송) +``` + +**새로운 방식 (서버 중심 처리):** + +``` +OAuth Provider → 서버 콜백 엔드포인트 → 상태별 클라이언트 리다이렉트 +``` + +### 4. 세션 기반 인증 시스템 전환 + +**기존**: JWT Access Token + Refresh Token +**현재**: Redis 기반 세션 관리 + +### 5. PASETO 기반 회원가입 토큰 시스템 + +**기존**: JWT 기반 일회성 토큰 +**현재**: PASETO v2.local 기반 보안 강화된 회원가입 토큰 + +### 6. HttpOnly 쿠키 보안 강화 + +**모든 인증 관련 토큰을 HttpOnly 쿠키로 전송**: +- `auth_token`: 세션 기반 인증 토큰 +- `sign_up_token`: PASETO 기반 회원가입 토큰 + +## 🔧 기술적 구현 세부사항 + +### OAuth 콜백 엔드포인트 구현 (/v1/api/auth/oauth/callback/{provider}) + +```24:29:src/main/java/com/juu/juulabel/auth/controller/AuthController.java +@Override +public ResponseEntity> login( + @PathVariable Provider provider, + @RequestParam(required = true) String code, + @RequestParam(required = true) String state) { + + authService.login(provider, code, state); + return CommonResponse.success(SuccessCode.SUCCESS); +} +``` + +**콜백 및 클라이언트 리다이렉트 엔드포인트 설정:** + +```142:148:src/main/resources/application.yml +app: + redirect: + base-server: http://localhost:8080 + base-client: http://localhost:3000 + callback: /v1/api/auth/oauth/callback + login: /app/login/redirect + signup: /app/sign-up/redirect + error: /app/error +``` + +### 사용자 상태별 스마트 리다이렉트 + +```153:198:src/main/java/com/juu/juulabel/auth/service/AuthService.java +private void handleExistingMember(Member member, OAuthUser oAuthUser) { + // 기존 활성 사용자 → 세션 생성 후 로그인 페이지로 + sessionManager.createSession(member); + httpResponseUtil.redirectToLogin(); +} + +private void handlePendingMember(Member member, OAuthUser oAuthUser) { + // 가입 대기 사용자 → 회원가입 토큰 생성 후 회원가입 페이지로 + signupTokenProvider.createToken(oAuthUser, nonce); + httpResponseUtil.redirectToSignup(); +} + +private void handleNewMember(OAuthUser oAuthUser) { + // 신규 사용자 → 펜딩 멤버 생성 후 회원가입 페이지로 + signupTokenProvider.createToken(oAuthUser, nonce); + Member newMember = Member.create(oAuthUser, nonce); + memberWriter.store(newMember); + httpResponseUtil.redirectToSignup(); +} +``` + +### Redis 기반 세션 관리 시스템 + +```22:66:src/main/java/com/juu/juulabel/member/token/UserSession.java +@RedisHash(value = "user_session") +public class UserSession implements Serializable { + @Id + private String id; + + @Indexed + private Long memberId; + + private String email; + private MemberRole role; + private String deviceId; + private String ipAddress; + private String userAgent; + private LocalDateTime createdAt; + private LocalDateTime lastAccessedAt; + + @TimeToLive + private Long ttl; // 7 days +} +``` + +**세션 생성 및 관리:** + +```53:75:src/main/java/com/juu/juulabel/redis/SessionManager.java +public void createSession(Member member) { + String sessionId = generateUniqueSessionId(); + UserSession session = new UserSession(sessionId, member); + + userSessionRepository.save(session); + cookieUtil.addCookie(AuthConstants.AUTH_TOKEN_NAME, sessionId, + AuthConstants.USER_SESSION_TTL); +} +``` + +### Apple JWT Token 검증 프로세스 + +```53:66:src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java +public AppleUser getAppleUserFromToken(List publicKeys, OAuthToken oauthToken) { + ApplePublicKey applePublicKey = getApplePublicKey(publicKeys, oauthToken); + PublicKey publicKey = buildPublicKey(applePublicKey); + + // Set up JWT parser with the public key + super.key = publicKey; + super.jwtParser = Jwts.parser().verifyWith(publicKey).build(); + + return extractFromClaims(oauthToken.idToken(), claims -> new AppleUser( + claims.get(SUB_CLAIM, String.class), + claims.get(EMAIL_CLAIM, String.class))); +} +``` + +**핵심 특징:** + +- Apple의 동적 공개키 검증 (JWK 방식) +- JWT Header의 `kid` 값과 Apple 공개키 매칭 +- RSA 공개키 재구성 및 서명 검증 + + +**아키텍처 장점:** + +- 각 프로바이더별 구현체의 느슨한 결합 +- Open-Closed Principle 준수 (확장에는 열려있고 수정에는 닫혀있음) +- 런타임 프로바이더 선택 및 의존성 주입 + +### PASETO 기반 회원가입 토큰 시스템 + +**JWT 대신 PASETO를 선택한 이유:** + +| 특성 | JWT | PASETO | +|------|-----|--------| +| **알고리즘 선택** | 개발자가 알고리즘 선택 (보안 위험) | 버전별 고정 알고리즘 (안전) | +| **암호화 방식** | 대칭/비대칭 선택 가능 | v2.local: ChaCha20-Poly1305 (대칭) | +| **보안성** | 알고리즘 혼동 공격 가능성 | 알고리즘 고정으로 공격 차단 | +| **성능** | RSA 서명 검증 시 느림 | 대칭키 암호화로 빠른 성능 | +| **용도** | 범용 토큰 | 특정 목적 (회원가입) 토큰 | + +**PASETO 토큰 생성:** + +```44:51:src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java +public void createToken(OAuthUser oAuthUser, String nonce) { + String token = builder() + .claim(EMAIL_CLAIM, oAuthUser.email()) + .claim(PROVIDER_CLAIM, oAuthUser.provider().name()) + .claim(PROVIDER_ID_CLAIM, oAuthUser.id()) + .claim(NONCE_CLAIM, nonce) + .claim(AUDIENCE_CLAIM_KEY, AUDIENCE_CLAIM) + .compact(); + cookieUtil.addCookie(AuthConstants.SIGN_UP_TOKEN_NAME, token, + (int) AuthConstants.SIGN_UP_TOKEN_DURATION.toSeconds()); +} +``` + +**PASETO 토큰 검증 및 보안 기능:** + +```59:95:src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java +public Member verifyToken(String token) { + Claims claims = parseClaims(token); + + // Extract and validate all claims at once + TokenClaims tokenClaims = extractTokenClaims(claims); + + // Validate audience first (fast check) + if (!AUDIENCE_CLAIM.equals(tokenClaims.audience())) { + throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); + } + + // Get member and validate + Member member = memberReader.getByEmail(tokenClaims.email()); + validateMemberAgainstToken(member, tokenClaims); + + return member; +} + +private void validateMemberAgainstToken(Member member, TokenClaims tokenClaims) { + // Check provider and provider ID + if (member.getProvider() != tokenClaims.provider()) { + throw new AuthException("Provider mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + + if (!member.getProviderId().equals(tokenClaims.providerId())) { + throw new AuthException("Provider ID mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + + if (!member.getNickname().equals(tokenClaims.nonce())) { + throw new AuthException("Token validation failed", ErrorCode.INVALID_AUTHENTICATION); + } + + // Check member status + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member already completed signup", ErrorCode.INVALID_AUTHENTICATION); + } +} +``` + +### HttpOnly 쿠키 보안 시스템 + +**포괄적 보안 설정:** + +```102:125:src/main/java/com/juu/juulabel/common/util/CookieUtil.java +private Cookie createSecureCookie(String name, String value, int maxAge) { + boolean isSecure = cookieProperties.isSecure(); + Cookie cookie = new Cookie(name, value); + + // Set domain only for production/secure environments + if (isSecure) { + cookie.setDomain(cookieProperties.getDomain()); + } + + cookie.setPath(cookieProperties.getPath()); + cookie.setHttpOnly(cookieProperties.isHttpOnly()); + cookie.setSecure(isSecure); + cookie.setMaxAge(maxAge); + + // Set SameSite attribute based on security requirements + String sameSite = isSecure ? cookieProperties.getSameSiteSecure() : cookieProperties.getSameSiteNonSecure(); + cookie.setAttribute("SameSite", sameSite); + + return cookie; +} +``` + +**쿠키 보안 속성:** + +```47:50:src/main/java/com/juu/juulabel/common/properties/CookieProperties.java +/** + * Whether to set HttpOnly flag on cookies by default. + * Default: true (recommended for security) + */ +private boolean httpOnly = true; +``` + +**세션 토큰과 회원가입 토큰 모두 HttpOnly 쿠키로 처리:** + +```64:71:src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java +private void handleSignUpRequest() { + String signupToken = cookieUtil.getCookie(AuthConstants.SIGN_UP_TOKEN_NAME); + + if (!StringUtils.hasText(signupToken)) { + throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); + } + + processSignUpToken(signupToken); +} +``` + +```73:80:src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java +private void handleRegularRequest() { + String authToken = cookieUtil.getCookie(AuthConstants.AUTH_TOKEN_NAME); + + if (StringUtils.hasText(authToken)) { + processUserSession(authToken); + } +} +``` + +## 🛡️ 보안 및 안정성 강화 + +### 1. PASETO 토큰 보안 이점 + +**JWT 대비 PASETO의 보안 장점:** + +- **알고리즘 고정**: `v2.local`에서 ChaCha20-Poly1305 암호화 고정 사용 +- **인증된 암호화**: 암호화와 인증을 동시에 제공하여 변조 방지 +- **키 관리 단순화**: 대칭키만 사용으로 키 관리 복잡성 감소 +- **타이밍 공격 방지**: 내장된 상수 시간 비교 연산 + +```46:51:src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java +protected PasetoV2LocalBuilder builder() { + return Pasetos.V2.LOCAL.builder() + .setSharedSecret(this.key) + .setIssuer(ISSUER) + .setAudience("juu-label-client") + .setIssuedAt(Instant.now()) + .setExpiration(Instant.now().plus(this.duration)); +} +``` + +### 2. HttpOnly 쿠키 보안 강화 + +**XSS 공격 방지:** +- JavaScript를 통한 토큰 접근 완전 차단 +- 브라우저가 자동으로 쿠키를 HTTP 요청에 포함 +- 클라이언트 측 토큰 저장소 관리 불필요 + +**CSRF 공격 대응:** +- SameSite 속성을 통한 크로스사이트 요청 제한 +- 개발 환경: `Lax` (기능성과 보안의 균형) +- 프로덕션 환경: `Strict` 또는 `None` (HTTPS 필수) + +### 3. 회원가입 토큰 특화 보안 + +**제한된 권한과 생명주기:** + +```13:14:src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +public static final String SIGN_UP_TOKEN_NAME = "sign_up_token"; +public static final Duration SIGN_UP_TOKEN_DURATION = Duration.ofMinutes(15); +``` + +**단일 목적 토큰:** +- 회원가입 완료 전용 토큰 (`audience: user-signup-completion`) +- 15분 짧은 만료 시간으로 공격 시간 윈도우 최소화 +- 사용자 상태(`PENDING`) 검증으로 중복 사용 방지 + +## 🔄 아키텍처 변경의 핵심 이점 + +### 1. OAuth 콜백 플로우 개선 + +**기존 문제점:** + +- 클라이언트에서 인가코드 처리 → CORS 이슈 +- 프론트엔드에 OAuth 로직 분산 → 복잡성 증가 +- 에러 처리의 일관성 부족 + +**개선된 방식:** + +```147:152:src/main/java/com/juu/juulabel/auth/service/AuthService.java +private OAuthUser getOAuthUser(Provider provider, String code) { + String redirectUrl = redirectProperties.getRedirectUrl(provider); + return providerFactory.getOAuthUser(provider, code, redirectUrl); +} +``` + +**장점:** + +- 서버에서 OAuth 플로우 완전 제어 +- 사용자 상태별 최적화된 리다이렉트 +- 통일된 에러 처리 및 로깅 + +### 2. 토큰 시스템 이원화 전략 + +| 토큰 종류 | 기술 | 용도 | 생명주기 | 보안 특성 | +|-----------|------|------|----------|-----------| +| **세션 토큰** | Redis 세션 | 로그인 사용자 인증 | 7일 | 즉시 무효화 가능 | +| **회원가입 토큰** | PASETO v2.local | 회원가입 완료 | 15분 | 암호화된 일회성 토큰 | + +**이원화 선택 이유:** + +- **목적별 최적화**: 각 용도에 맞는 최적의 기술 선택 +- **보안 계층화**: 서로 다른 보안 메커니즘으로 공격 벡터 분산 +- **성능 최적화**: 세션은 Redis 캐시, 회원가입은 암호화 토큰 + +### 3. 세션 vs JWT 토큰 비교 + +| 특성 | JWT 토큰 | 세션 기반 | PASETO (회원가입) | +| ---------- | -------------------------- | --------------------- | ----------------- | +| **확장성** | Stateless (서버 부하 적음) | Stateful (Redis 의존) | Stateless | +| **보안성** | 토큰 탈취 시 만료까지 유효 | 즉시 세션 무효화 가능 | 암호화된 일회성 토큰 | +| **추적성** | 토큰 사용 추적 어려움 | 세션 활동 완전 추적 | 단일 목적 추적 | +| **복잡성** | 토큰 관리 로직 복잡 | 세션 관리 직관적 | 단순한 검증 로직 | + +**세션 방식 선택 이유:** + +- **보안 우선**: 토큰 탈취 시 즉시 무효화 가능 +- **확장성 확보**: 추후 멀티 디바이스 로그인 제어 용이 +- **감사 로그**: 세션 기반 사용자 활동 추적 + +## 📋 설정 및 환경 구성 + +**실제 리다이렉트 플로우:** + +1. `OAuth Provider` → `http://localhost:8080/v1/api/auth/oauth/callback/{provider}` +2. 서버에서 사용자 상태 확인 후 적절한 클라이언트 페이지로 리다이렉트 +3. `http://localhost:3000/app/{login|signup|error}` + +### Redis 세션 저장소 설정 + +```12:18:src/main/resources/application.yml +data: + redis: + host: localhost + port: 6379 + ssl: + enabled: true +``` + +### Apple OAuth 설정 + +```68:72:src/main/resources/application.yml +apple: + clientId: your-apple-client-id + clientSecret: your-apple-client-secret + authorization-grant-type: authorization_code + redirectUri: "http://localhost:3000/login/oauth2/code/apple" +``` + +### 쿠키 보안 설정 + +```yaml +app: + cookie: + secure: false # 개발 환경, 프로덕션에서는 true + domain: juulabel.com + path: / + sameSiteSecure: None # HTTPS 환경용 + sameSiteNonSecure: Lax # HTTP 환경용 + httpOnly: true # XSS 방지를 위해 항상 true +``` + +## 🚀 성능 최적화 + +### 1. 세션 관리 최적화 + +```128:138:src/main/java/com/juu/juulabel/redis/SessionManager.java +private void updateSessionActivity(UserSession session) { + try { + session.updateLastAccessed(); + userSessionRepository.save(session); + } catch (Exception e) { + log.warn("Failed to update session activity for session: {}", session.getId(), e); + // Non-critical operation, don't throw exception + } +} +``` + +### 2. Redis 인덱스 최적화 + +```18:20:src/main/java/com/juu/juulabel/member/token/UserSession.java +@Indexed +private Long memberId; // 사용자별 세션 조회 최적화 +``` + +### 3. PASETO 성능 최적화 + +**ObjectMapper 및 상수 재사용:** + +```34:34:src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java +private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); +``` + +**대칭키 암호화 성능:** +- ChaCha20-Poly1305: RSA 대비 약 10배 빠른 암호화/복호화 +- 메모리 사용량 감소: 큰 RSA 키 대신 32바이트 대칭키 사용 + +### 4. 쿠키 처리 최적화 + +**쿠키 존재 여부 빠른 확인:** + +```96:99:src/main/java/com/juu/juulabel/common/util/CookieUtil.java +public boolean cookieExists(String name) { + return getCookie(name) != null; +} +``` + +## 🧪 테스트 전략 + +### 단위 테스트 고려사항 + +- Apple JWT 토큰 검증 로직 +- 세션 생성 및 검증 +- Factory Pattern의 프로바이더 선택 로직 +- **PASETO 토큰 생성 및 검증** +- **HttpOnly 쿠키 설정 확인** + +### 통합 테스트 권장사항 + +- OAuth Provider별 End-to-End 플로우 +- 세션 기반 인증 통합 테스트 +- 리다이렉트 시나리오 검증 +- **PASETO 회원가입 플로우 전체 테스트** +- **쿠키 보안 속성 검증** + +--- diff --git a/src/main/java/com/juu/juulabel/JuulabelApplication.java b/src/main/java/com/juu/juulabel/JuulabelApplication.java index 49c3e8d..6c19a6b 100644 --- a/src/main/java/com/juu/juulabel/JuulabelApplication.java +++ b/src/main/java/com/juu/juulabel/JuulabelApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; @SpringBootApplication @EnableRedisRepositories +@EnableConfigurationProperties public class JuulabelApplication { public static void main(String[] args) { diff --git a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java b/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java deleted file mode 100644 index a7754f2..0000000 --- a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.juu.juulabel.admin; - -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.service.MemberService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -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") -@RestController -@RequiredArgsConstructor -public class TestAccessTokenController { - - private final AccessTokenProvider accessTokenProvider; - private final MemberService memberService; - - @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 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 0aa8468..c72cb91 100644 --- a/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java @@ -8,25 +8,19 @@ 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 org.springframework.web.bind.annotation.RequestParam; -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; import com.juu.juulabel.common.dto.request.WithdrawalRequest; -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.response.CommonResponse; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.Provider; -import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import io.swagger.v3.oas.annotations.headers.Header; @@ -42,11 +36,11 @@ public interface AuthApiDocs { }) @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") @ApiResponse(responseCode = "401", description = "인증 실패") - @PostMapping("/login/{provider}") - public ResponseEntity> login( + @GetMapping("/oauth/callback/{provider}") + public ResponseEntity> login( @Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider, - CsrfToken csrfToken, - @Valid @RequestBody OAuthLoginRequest request); + @RequestParam(required = true) String code, + @RequestParam(required = true) String state); @Operation(summary = "회원가입", description = "새로운 회원 등록 및 초기 토큰 발급") @ApiResponse(responseCode = "200", description = "회원가입 성공", headers = { @@ -54,32 +48,17 @@ public ResponseEntity> login( }) @ApiResponse(responseCode = "400", description = "유효성 검사 실패, 중복된 이메일 또는 닉네임") @PostMapping("/sign-up") - public ResponseEntity> signUp( - @AuthenticationPrincipal SignUpToken signUpToken, + public ResponseEntity> signUp( + @AuthenticationPrincipal Member member, @Valid @RequestBody SignUpMemberRequest request); - @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 토큰 로테이션") - @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 갱신", schema = @Schema(type = "string")), - }) - @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) - }) - @ApiResponse(responseCode = "403", description = "토큰 재사용 감지", headers = { - @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) - }) - @PostMapping("/refresh") - public ResponseEntity> refresh( - @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken); - @Operation(summary = "로그아웃", description = "현재 디바이스의 리프레시 토큰 무효화") @ApiResponse(responseCode = "200", description = "로그아웃 성공", headers = { @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) }) @ApiResponse(responseCode = "401", description = "인증되지 않은 요청") @PostMapping("/logout") - public ResponseEntity> logout( - @AuthenticationPrincipal Member member); + public ResponseEntity> logout(); @Operation(summary = "회원 탈퇴", description = "회원 계정 삭제 및 모든 토큰 무효화") @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공", headers = { 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 4765cd7..561bf06 100644 --- a/src/main/java/com/juu/juulabel/auth/controller/AuthController.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java @@ -1,14 +1,8 @@ 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; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; -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.code.SuccessCode; import com.juu.juulabel.common.response.CommonResponse; import com.juu.juulabel.member.domain.Member; @@ -19,7 +13,6 @@ 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 @@ -29,35 +22,30 @@ public class AuthController implements AuthApiDocs { private final AuthService authService; @Override - public ResponseEntity> login( + public ResponseEntity> login( @PathVariable Provider provider, - CsrfToken csrfToken, - @Valid @RequestBody OAuthLoginRequest request) { - csrfToken.getToken(); + @RequestParam(required = true) String code, + @RequestParam(required = true) String state) { + + authService.login(provider, code, state); - return CommonResponse.success(SuccessCode.SUCCESS, authService.login(request)); + return CommonResponse.success(SuccessCode.SUCCESS); } @Override - public ResponseEntity> signUp( - @AuthenticationPrincipal SignUpToken signUpToken, + public ResponseEntity> signUp( + @AuthenticationPrincipal Member member, @Valid @RequestBody SignUpMemberRequest request) { - return CommonResponse.success(SuccessCode.SUCCESS, authService.signUp(signUpToken, request)); - } - - @Override - public ResponseEntity> refresh( - @CookieValue(value = AuthConstants.REFRESH_TOKEN_NAME, required = true) String refreshToken) { + authService.signUp(member, request); - return CommonResponse.success(SuccessCode.SUCCESS, authService.refresh(refreshToken)); + return CommonResponse.success(SuccessCode.SUCCESS); } @Override - public ResponseEntity> logout( - @AuthenticationPrincipal Member member) { + public ResponseEntity> logout() { - authService.logout(member.getId()); + authService.logout(); return CommonResponse.success(SuccessCode.SUCCESS); } diff --git a/src/main/java/com/juu/juulabel/auth/domain/ClientId.java b/src/main/java/com/juu/juulabel/auth/domain/ClientId.java deleted file mode 100644 index 0a7b638..0000000 --- a/src/main/java/com/juu/juulabel/auth/domain/ClientId.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.juu.juulabel.auth.domain; - -import java.util.Arrays; -import java.util.Optional; - -public enum ClientId { - WEB("web-client"), - IOS("ios-app"), - ANDROID("android-app"), - ADMIN("admin-panel"); - - private final String value; - - ClientId(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - - public static Optional from(String value) { - return Arrays.stream(values()) - .filter(c -> c.value.equals(value)) - .findFirst(); - } -} diff --git a/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java b/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java deleted file mode 100644 index 55f423c..0000000 --- a/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.juu.juulabel.auth.domain; - -import lombok.*; - -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HASH_PREFIX; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_INDEX_PREFIX; - -import java.io.Serializable; - -import java.util.List; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class RefreshToken implements Serializable { - - private String token; - - private String hashedToken; - - private Long memberId; - - private String deviceId; - - private ClientId clientId; - - private String ipAddress; - - private String userAgent; - - private Long ttl; - - private boolean revoked; - - @Builder - public RefreshToken(String token, String hashedToken, Long memberId, ClientId clientId, String deviceId, - String ipAddress, String userAgent) { - this.token = token; - this.hashedToken = hashedToken; - this.memberId = memberId; - this.clientId = clientId; - this.deviceId = deviceId; - this.ipAddress = ipAddress; - this.userAgent = userAgent; - this.ttl = REFRESH_TOKEN_DURATION.getSeconds(); - this.revoked = false; - } - - public String getTokenKey() { - return REFRESH_TOKEN_HASH_PREFIX + ":" + hashedToken; - } - - public String getIndexKey() { - return REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":" + clientId + ":" + deviceId; - } - - public List getArgs() { - return List.of(memberId.toString(), clientId.toString(), deviceId, ipAddress, userAgent, ttl.toString()); - } - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java b/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java deleted file mode 100644 index 932ac67..0000000 --- a/src/main/java/com/juu/juulabel/auth/domain/SocialLink.java +++ /dev/null @@ -1,112 +0,0 @@ -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.common.util.HttpRequestUtil; -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; - - 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 nonce) { - this.hashedEmail = hashedEmail; - this.provider = provider; - this.providerId = providerId; - this.deviceId = deviceId; - this.userAgent = userAgent; - this.ipAddress = ipAddress; - this.usedAt = null; - this.nonce = nonce; - this.ttl = SOCIAL_LINK_DURATION.getSeconds(); - } - - /** - * Validates the social link against provided parameters for security purposes. - * Throws AuthException if validation fails. - */ - public void validate(SignUpToken signUpToken) { - // Check if already used - if (isAlreadyUsed()) { - throw new AuthException(ErrorCode.SOCIAL_LINK_ALREADY_USED); - } - - // Validate parameters match stored values - // DISABLE IN DEVELOPMENT ENVIRONMENT - if (!isValidationParametersMatch(signUpToken)) { - throw new AuthException("Validation failed due to parameter mismatch"); - } - } - - /** - * Marks this social link as used with current timestamp. - * Can only be used once. - */ - public void markAsUsed() { - if (isAlreadyUsed()) { - throw new AuthException(ErrorCode.SOCIAL_LINK_ALREADY_USED); - } - this.usedAt = Instant.now().getEpochSecond(); - } - - /** - * Checks if this social link has already been used. - */ - public boolean isAlreadyUsed() { - return this.usedAt != null; - } - - /** - * Checks if validation parameters match stored values. - * Uses efficient short-circuit evaluation. - */ - private boolean isValidationParametersMatch(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/LoginRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java deleted file mode 100644 index 4ea283c..0000000 --- a/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.redis.RedisScriptExecutor; - -@Component -public class LoginRefreshTokenScriptExecutor implements RedisScriptExecutor { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - public LoginRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { - this.redisTemplate = redisTemplate; - String scriptText = Files.readString( - new ClassPathResource("scripts/login_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); - this.redisScript = RedisScript.of(scriptText, Object.class); - } - - @Override - public Object execute(RefreshToken refreshToken, Object... args) { - String newTokenKey = refreshToken.getTokenKey(); - String indexKey = refreshToken.getIndexKey(); - - List keys = List.of(newTokenKey, indexKey); - List arguments = refreshToken.getArgs(); - - try { - return redisTemplate.execute(redisScript, keys, arguments.toArray()); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } - -} diff --git a/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java deleted file mode 100644 index 77480de..0000000 --- a/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -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 { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - public RevokeRefreshTokenByIndexKeyExecutor(RedisTemplate redisTemplate) throws IOException { - this.redisTemplate = redisTemplate; - String scriptText = Files.readString( - new ClassPathResource("scripts/revoke_refresh_token_by_index_key.lua").getFile().toPath(), - StandardCharsets.UTF_8); - this.redisScript = RedisScript.of(scriptText, Object.class); - } - - @Override - public Object execute(String indexKey, Object... args) { - try { - return redisTemplate.execute(redisScript, List.of(indexKey)); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } - -} diff --git a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java deleted file mode 100644 index 6993058..0000000 --- a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.redis.RedisScriptExecutor; - -@Component -public class RotateRefreshTokenScriptExecutor implements RedisScriptExecutor { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - 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); - } - - @Override - public Object execute(RefreshToken refreshToken, Object... args) { - String newTokenKey = refreshToken.getTokenKey(); - String indexKey = refreshToken.getIndexKey(); - String oldTokenKey = args[0].toString(); - - List keys = Arrays.asList(newTokenKey, indexKey, oldTokenKey); - List arguments = refreshToken.getArgs(); - - try { - return redisTemplate.execute(redisScript, keys, arguments.toArray()); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } - - @Override - public void handleRedisScriptError(String errorMessage) { - 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")) { - throw new AuthException(ErrorCode.REFRESH_TOKEN_REUSE_DETECTED); - } else if (errorMessage.contains("DEVICE_ID_MISMATCH")) { - throw new AuthException(ErrorCode.DEVICE_ID_MISMATCH); - } else { - throw new AuthException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java deleted file mode 100644 index 46fccad..0000000 --- a/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.juu.juulabel.auth.executor; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.List; - -import org.springframework.core.io.ClassPathResource; -import org.springframework.data.redis.RedisSystemException; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.redis.RedisScriptExecutor; - -@Component -public class SaveRefreshTokenScriptExecutor implements RedisScriptExecutor { - - private final RedisTemplate redisTemplate; - private final RedisScript redisScript; - - public SaveRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { - this.redisTemplate = redisTemplate; - String scriptText = Files.readString( - new ClassPathResource("scripts/save_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); - this.redisScript = RedisScript.of(scriptText, Object.class); - } - - @Override - public Object execute(RefreshToken refreshToken, Object... args) { - String newTokenKey = refreshToken.getTokenKey(); - String indexKey = refreshToken.getIndexKey(); - - List keys = Arrays.asList(newTokenKey, indexKey); - List arguments = refreshToken.getArgs(); - - try { - return redisTemplate.execute(redisScript, keys, arguments.toArray()); - } catch (RedisSystemException e) { - handleRedisException(e); - throw e; // This line will never be reached due to the exception thrown above - } - } -} diff --git a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java deleted file mode 100644 index 4dbe50e..0000000 --- a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.juu.juulabel.auth.repository; - -import com.juu.juulabel.auth.domain.ClientId; -import com.juu.juulabel.auth.domain.RefreshToken; - -public interface RefreshTokenRepository { - - /** - * Saves a refresh token - */ - void save(RefreshToken refreshToken); - - /** - * Rotate - */ - void rotate(RefreshToken refreshToken, String hashedOldToken); - - /** - * Login - */ - void login(RefreshToken refreshToken); - - /** - * Revokes all refresh tokens for a member and device - */ - void revokeByMemberAndDevice(Long memberId, ClientId clientId, String deviceId); - - /** - * Revokes all refresh tokens for a member - */ - void revokeAllByMember(Long memberId); - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java deleted file mode 100644 index 9da8ac8..0000000 --- a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepositoryImpl.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.juu.juulabel.auth.repository; - -import com.juu.juulabel.auth.domain.ClientId; -import com.juu.juulabel.auth.domain.RefreshToken; -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 RefreshTokenRepositoryImpl implements RefreshTokenRepository { - - private final ScriptRegistry scriptRegistry; - - @Override - public void save(RefreshToken refreshToken) { - scriptRegistry.get(RedisScriptName.SAVE_REFRESH_TOKEN).execute(refreshToken); - } - - @Override - public void rotate(RefreshToken refreshToken, String hashedOldToken) { - - String oldTokenKey = AuthConstants.REFRESH_TOKEN_HASH_PREFIX + ":" + hashedOldToken; - - scriptRegistry.get(RedisScriptName.ROTATE_REFRESH_TOKEN).execute(refreshToken, oldTokenKey); - } - - @Override - public void login(RefreshToken refreshToken) { - scriptRegistry.get(RedisScriptName.LOGIN_REFRESH_TOKEN).execute(refreshToken); - } - - @Override - public void revokeByMemberAndDevice(Long memberId, ClientId clientId, String deviceId) { - String indexKey = AuthConstants.REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":" + clientId + ":" + deviceId - + ":*"; - - scriptRegistry.get(RedisScriptName.REVOKE_REFRESH_TOKEN_BY_INDEX_KEY).execute(indexKey); - } - - @Override - public void revokeAllByMember(Long memberId) { - String indexKey = AuthConstants.REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":*"; - - scriptRegistry.get(RedisScriptName.REVOKE_REFRESH_TOKEN_BY_INDEX_KEY).execute(indexKey); - } - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java b/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java deleted file mode 100644 index 7f51aab..0000000 --- a/src/main/java/com/juu/juulabel/auth/repository/SocialLinkRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -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/repository/UserSessionRepository.java b/src/main/java/com/juu/juulabel/auth/repository/UserSessionRepository.java new file mode 100644 index 0000000..6de2056 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/UserSessionRepository.java @@ -0,0 +1,10 @@ +package com.juu.juulabel.auth.repository; + +import com.juu.juulabel.member.token.UserSession; + +import org.springframework.data.repository.CrudRepository; + +public interface UserSessionRepository extends CrudRepository { + + void deleteAllByMemberId(Long memberId); +} \ 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 0e90e97..c1b1777 100644 --- a/src/main/java/com/juu/juulabel/auth/service/AuthService.java +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -1,22 +1,27 @@ 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; -import com.juu.juulabel.common.dto.response.RefreshResponse; -import com.juu.juulabel.common.dto.response.SignUpMemberResponse; import com.juu.juulabel.common.factory.OAuthProviderFactory; +import com.juu.juulabel.common.properties.RedirectProperties; +import com.juu.juulabel.common.provider.token.paseto.SignupTokenProvider; +import com.juu.juulabel.common.util.HttpResponseUtil; import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.Provider; 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.WithdrawalRecordWriter; import com.juu.juulabel.member.util.MemberUtils; +import com.juu.juulabel.redis.SessionManager; + +import io.sentry.Sentry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; import java.util.Optional; import java.util.UUID; @@ -26,8 +31,8 @@ /** * Service for handling authentication operations including login, signup, - * refresh, logout, and account deletion. - * Provides secure OAuth-based authentication with token management. + * logout, and account deletion. + * Provides secure OAuth-based authentication with session management. */ @Slf4j @Service @@ -39,115 +44,155 @@ public class AuthService { private final WithdrawalRecordWriter withdrawalRecordWriter; private final MemberUtils memberUtils; private final OAuthProviderFactory providerFactory; - private final TokenService tokenService; - private final SocialLinkService socialLinkService; + private final SessionManager sessionManager; + private final RedirectProperties redirectProperties; + private final SignupTokenProvider signupTokenProvider; + private final HttpResponseUtil httpResponseUtil; /** - * 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) + * Handles OAuth login flow for both new and existing members. + * + * @param provider OAuth provider (Google, GitHub, etc.) + * @param code Authorization code from OAuth provider + * @param state State parameter from OAuth provider */ @Transactional - 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()); + public void login(Provider provider, String code, String state) { + try { + + // Get OAuth user info + OAuthUser oAuthUser = getOAuthUser(provider, code); + + // Process member based on existence and status + Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); + + if (memberOpt.isPresent()) { + Member member = memberOpt.get(); + if (member.getStatus() == MemberStatus.PENDING) { + handlePendingMember(member, oAuthUser); + } else { + handleExistingMember(member, oAuthUser); + } + } else { + handleNewMember(oAuthUser); + } + + } catch (Exception e) { + Sentry.captureException(e); + httpResponseUtil.redirectToError(); + } } /** - * 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 + * Completes member registration with additional information. + * + * @param member Pre-authenticated member from signup token + * @param signUpRequest Additional member registration details */ @Transactional - public SignUpMemberResponse signUp(SignUpToken signUpToken, SignUpMemberRequest signUpRequest) { - - final Member member = Member.create(signUpRequest, signUpToken); + public void signUp(Member member, SignUpMemberRequest signUpRequest) { + // Validate member status + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member is not in pending status", ErrorCode.INVALID_AUTHENTICATION); + } + + // Complete signup process + member.completeSignUp(signUpRequest); memberWriter.store(member); - // Process additional member data (alcohol types, terms agreements) if provided + // Process additional member data memberUtils.processMemberData(member, signUpRequest); - // Generate authentication tokens for the new member - String accessToken = tokenService.signUp(member); - - return new SignUpMemberResponse(member.getId(), accessToken); + // Create session for the newly registered member + sessionManager.createSession(member); } /** - * Refreshes an access token using a valid refresh token. - * - * @param refreshToken the current refresh token - * @return RefreshResponse with the new access token + * Logs out current user by invalidating their session. */ - @Transactional(readOnly = true) - public RefreshResponse refresh(String refreshToken) { - final String accessToken = tokenService.rotate(refreshToken); - return new RefreshResponse(accessToken); - } - - /** - * 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); + public void logout() { + try { + sessionManager.invalidateSession(); + } catch (Exception e) { + log.warn("Error during logout: {}", e.getMessage()); + // Don't throw exception for logout failures + } } /** - * 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 + * Permanently deletes member account and creates audit record. + * + * @param loginMember Authenticated member requesting deletion + * @param request Withdrawal request with reason */ @Transactional public void deleteAccount(Member loginMember, WithdrawalRequest request) { + // Validate member can be deleted + if (loginMember.getStatus() == MemberStatus.WITHDRAWAL) { + throw new AuthException("Member already withdrawn", ErrorCode.MEMBER_WITHDRAWN); + } // Mark member as deleted (soft delete) loginMember.deleteAccount(); - // Create audit record for withdrawal - final WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( + // Create audit record + WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname()); withdrawalRecordWriter.store(withdrawalRecord); - // Revoke all authentication tokens - tokenService.withdraw(loginMember.getId()); + // Revoke all sessions + sessionManager.invalidateAllUserSessions(loginMember.getId()); + } + + // Private helper methods + + private OAuthUser getOAuthUser(Provider provider, String code) { + String redirectUrl = redirectProperties.getRedirectUrl(provider); + return providerFactory.getOAuthUser(provider, code, redirectUrl); + } + + private void handleExistingMember(Member member, OAuthUser oAuthUser) { + // Validate member status + if (member.getStatus() == MemberStatus.WITHDRAWAL) { + throw new AuthException("Member has been withdrawn", ErrorCode.MEMBER_WITHDRAWN); + } + + if (member.getStatus() == MemberStatus.INACTIVE) { + throw new AuthException("Member is not active", ErrorCode.MEMBER_NOT_ACTIVE); + } + + // Validate OAuth user matches member + member.validateLoginMember(oAuthUser); + + // Create session and redirect + sessionManager.createSession(member); + httpResponseUtil.redirectToLogin(); + } + + private void handlePendingMember(Member member, OAuthUser oAuthUser) { + // Validate OAuth user matches pending member + member.validateLoginMember(oAuthUser); + + // Generate new signup token for existing pending member + String nonce = member.getNickname(); // Use existing nonce + signupTokenProvider.createToken(oAuthUser, nonce); + + httpResponseUtil.redirectToSignup(); + } + + private void handleNewMember(OAuthUser oAuthUser) { + // Generate unique nonce for new member + String nonce = UUID.randomUUID().toString(); + + // Create signup token + signupTokenProvider.createToken(oAuthUser, nonce); + + // Create new pending member + Member newMember = Member.create(oAuthUser, nonce); + memberWriter.store(newMember); + + httpResponseUtil.redirectToSignup(); } } \ 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 deleted file mode 100644 index 6261321..0000000 --- a/src/main/java/com/juu/juulabel/auth/service/SocialLinkService.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.juu.juulabel.auth.service; - -import org.springframework.stereotype.Service; - -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.HashingUtil; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.common.util.IpAddressExtractor; -import com.juu.juulabel.member.request.OAuthUser; -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(OAuthUser oAuthUser, String nonce) { - SocialLink socialLink = SocialLink.builder() - .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(SignUpToken signUpToken) { - String hashedEmail = HashingUtil.hashSha256(signUpToken.email()); - - SocialLink socialLink = socialLinkRepository.findById(hashedEmail) - .orElseThrow(() -> new AuthException(ErrorCode.SOCIAL_LINK_NOT_FOUND)); - - 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 deleted file mode 100644 index 2795cb1..0000000 --- a/src/main/java/com/juu/juulabel/auth/service/TokenService.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.juu.juulabel.auth.service; - -import com.juu.juulabel.auth.domain.ClientId; -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.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.member.domain.Member; -import com.juu.juulabel.member.request.OAuthUser; - -import lombok.RequiredArgsConstructor; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 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 AccessTokenProvider accessTokenProvider; - private final RefreshTokenProvider refreshTokenProvider; - private final SignupTokenProvider signupTokenProvider; - private final RefreshTokenRepository refreshTokenRepository; - private final CookieUtil cookieUtil; - - /** - * 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 String signUp(Member member) { - return createAccessAndRefreshToken(member, refreshTokenRepository::save); - } - - /** - * Creates signup ready token for OAuth flow with enhanced validation. - * - * @param oAuthUser the OAuth user information - * @param nonce the security nonce - */ - public String createSignUpReadyToken(OAuthUser oAuthUser, String nonce) { - return signupTokenProvider.createToken(oAuthUser, nonce); - } - - /** - * Creates tokens for login and manages device-specific token rotation. - * - * @param member the member to create tokens for - */ - @Transactional - public String login(Member member) { - return createAccessAndRefreshToken(member, refreshTokenRepository::login); - } - - /** - * 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 String rotate(String oldToken) { - - 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()); - - return accessTokenProvider.createToken(member); - } - - /** - * Revokes refresh token for logout with device-specific cleanup. - * - * @param memberId the member ID for logout - */ - @Transactional - 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 account withdrawal. - * Performs complete token cleanup for account deletion. - * - * @param memberId the member ID for account withdrawal - */ - @Transactional - public void withdraw(Long memberId) { - refreshTokenRepository.revokeAllByMember(memberId); - cookieUtil.removeCookie(AuthConstants.REFRESH_TOKEN_NAME); - } - - /** - * 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()); - return accessTokenProvider.createToken(member); - - } - - /** - * 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/client/AppleAuthClient.java b/src/main/java/com/juu/juulabel/common/client/AppleAuthClient.java new file mode 100644 index 0000000..e551d2b --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/client/AppleAuthClient.java @@ -0,0 +1,28 @@ +package com.juu.juulabel.common.client; + +import java.util.List; + +import org.springframework.cloud.openfeign.FeignClient; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.juu.juulabel.member.request.ApplePublicKey; +import com.juu.juulabel.member.token.AppleToken; + +@FeignClient(value = "apple-auth", url = "${api.apple.aauth}") +public interface AppleAuthClient { + + @PostMapping(value = "/auth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + AppleToken generateOAuthToken( + @RequestParam(name = "code") String code, + @RequestParam(name = "client_id") String clientId, + @RequestParam(name = "client_secret") String clientSecret, + @RequestParam(name = "redirect_uri") String redirectUri, + @RequestParam(name = "grant_type") String grantType); + + @GetMapping("/auth/keys") + List getApplePublicKeys(); +} diff --git a/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java b/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java index 6e201a4..7886d74 100644 --- a/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java +++ b/src/main/java/com/juu/juulabel/common/client/GoogleAuthClient.java @@ -11,9 +11,9 @@ public interface GoogleAuthClient { @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) GoogleToken generateOAuthToken(@RequestParam(name = "code") String code, - @RequestParam(name = "client_id") String clientId, - @RequestParam(name = "client_secret") String clientSecret, - @RequestParam(name = "redirect_uri") String redirectUri, - @RequestParam(name = "grant_type") String grantType); + @RequestParam(name = "client_id") String clientId, + @RequestParam(name = "client_secret") String clientSecret, + @RequestParam(name = "redirect_uri") String redirectUri, + @RequestParam(name = "grant_type") String grantType); } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java b/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java index 8ae66d2..161f32e 100644 --- a/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java +++ b/src/main/java/com/juu/juulabel/common/client/KakaoApiClient.java @@ -1,6 +1,5 @@ package com.juu.juulabel.common.client; - import com.juu.juulabel.member.request.KakaoUser; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.http.HttpHeaders; diff --git a/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java b/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java index 9bcba7d..55cab86 100644 --- a/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java +++ b/src/main/java/com/juu/juulabel/common/client/KakaoAuthClient.java @@ -11,8 +11,8 @@ public interface KakaoAuthClient { @PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) KakaoToken generateOAuthToken(@RequestParam(name = "grant_type") String grantType, - @RequestParam(name = "client_id") String clientId, - @RequestParam(name = "redirect_uri") String redirectUri, - @RequestParam(name = "code") String code, - @RequestParam(name = "client_secret") String clientSecret); + @RequestParam(name = "client_id") String clientId, + @RequestParam(name = "redirect_uri") String redirectUri, + @RequestParam(name = "code") String code, + @RequestParam(name = "client_secret") String clientSecret); } 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 ddf8cc2..b525ebe 100644 --- a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java @@ -1,7 +1,8 @@ package com.juu.juulabel.common.config; -import com.juu.juulabel.common.filter.JwtAuthorizationFilter; -import com.juu.juulabel.common.filter.JwtExceptionFilter; +import com.juu.juulabel.common.filter.AuthorizationFilter; +import com.juu.juulabel.common.filter.AuthExceptionFilter; +import com.juu.juulabel.common.handler.CustomAccessDeniedHandler; import com.juu.juulabel.member.domain.MemberRole; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -14,8 +15,6 @@ 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; @@ -27,13 +26,15 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthorizationFilter jwtAuthorizationFilter; - private final JwtExceptionFilter jwtExceptionFilter; + private final AuthorizationFilter authorizationFilter; + private final AuthExceptionFilter authExceptionFilter; + private final CustomAccessDeniedHandler customAccessDeniedHandler; - // 완전 공개 엔드 포인트 (우선순위 최상) + // 완전 공개 엔드 포인트 (우선순위 최상) - 가장 자주 호출되는 것을 앞에 private static final String[] PUBLIC_ENDPOINTS = { - "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", - "/v1/api/auth/refresh", "/v1/api/auth/login/**" + "/error", "/favicon.ico", "/", + "/swagger-ui/**", "/v3/api-docs/**", + "/actuator/**" }; // 관리자 전용 엔드포인트 @@ -41,7 +42,7 @@ public class SecurityConfig { "/v1/api/admins/permission/test" }; - // 인증/인가 필요한 특정 GET 엔드포인트 + // 인증/인가 필요한 특정 GET 엔드포인트 - 성능을 위해 구체적인 패턴을 앞에 private static final String[] PROTECTED_GET_ENDPOINTS = { "/v1/api/members/my-info", "/v1/api/members/my-space", @@ -50,29 +51,23 @@ public class SecurityConfig { "/v1/api/members/alcoholic-drinks/my" }; - // CORS 허용 원본 + // CORS 허용 원본 - 개발 환경을 앞에 배치 private static final String[] ALLOWED_ORIGINS = { - "http://localhost:8084", "http://localhost:8080", + "http://localhost:8084", "http://localhost:5173", "http://localhost:3000", + "https://juulabel.com", "https://api.juulabel.com", "https://dev.juulabel.com", "https://qa.juulabel.com", - "https://juulabel.com", "https://d3jwyw9rpnxu8p.cloudfront.net" }; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http - - .csrf(csrf -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) - .requireCsrfProtectionMatcher(request -> request.getServletPath() - .equals("/v1/api/auth/refresh"))) - + .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement(session -> session @@ -85,13 +80,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // Configure CORS .cors(cors -> cors.configurationSource(corsConfigurationSource())) + // Configure exception handling + .exceptionHandling(exceptions -> exceptions + .accessDeniedHandler(customAccessDeniedHandler)) + // Configure authorization rules .authorizeHttpRequests(this::configureAuthorization) // Add custom filters - .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) - + .addFilterBefore(authorizationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(authExceptionFilter, AuthorizationFilter.class) .build(); } 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 fb3118a..751fd4e 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -9,16 +9,12 @@ public class AuthConstants { public static final String TOKEN_PREFIX = "Bearer "; - public static final String REFRESH_TOKEN_NAME = "REFRESH-TOKEN"; + public static final String AUTH_TOKEN_NAME = "auth_token"; + public static final String SIGN_UP_TOKEN_NAME = "sign_up_token"; - public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(15); - public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(15); + public static final int USER_SESSION_TTL = 60 * 60 * 24 * 7; public static final Duration SIGN_UP_TOKEN_DURATION = Duration.ofMinutes(15); - 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"; + // Redis Prefix + public static final String USER_SESSION_PREFIX = "user_session"; } diff --git a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java index a6b5354..146d367 100644 --- a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java +++ b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java @@ -12,7 +12,7 @@ @Component public class ProviderConverter implements Converter { - private static final Set ALLOWED_PROVIDERS = Set.of(Provider.GOOGLE, Provider.KAKAO); + private static final Set ALLOWED_PROVIDERS = Set.of(Provider.GOOGLE, Provider.KAKAO, Provider.APPLE); @Override public Provider convert(String source) { 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 3a4358e..349813e 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 @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +@Deprecated public record OAuthLoginRequest( @NotBlank(message = "인가코드가 누락되었습니다.") String code, @NotNull(message = "리다이렉트 URI가 누락되었습니다.") String redirectUri, 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 deleted file mode 100644 index e60d046..0000000 --- a/src/main/java/com/juu/juulabel/common/dto/response/LoginResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.juu.juulabel.common.dto.response; - -public record LoginResponse( - String accessToken, - String signUpToken, - String email) { -} diff --git a/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java deleted file mode 100644 index 2a1418f..0000000 --- a/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.juu.juulabel.common.dto.response; - -public record RefreshResponse(String accessToken) { - -} diff --git a/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java index 20726b9..1331925 100644 --- a/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java +++ b/src/main/java/com/juu/juulabel/common/dto/response/RelationSearchResponse.java @@ -6,6 +6,5 @@ @Schema(description = "연관 검색어 리스트 조회 응답") public record RelationSearchResponse( - List relationSearch -) { -} + List relationSearch) { +} \ No newline at end of file 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 6b71e9c..6bbb722 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,5 +1,6 @@ package com.juu.juulabel.common.dto.response; +@Deprecated public record SignUpMemberResponse( Long memberId, String accessToken) { diff --git a/src/main/java/com/juu/juulabel/common/exception/CustomPasetoException.java b/src/main/java/com/juu/juulabel/common/exception/CustomPasetoException.java new file mode 100644 index 0000000..c35f1bf --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/exception/CustomPasetoException.java @@ -0,0 +1,27 @@ +package com.juu.juulabel.common.exception; + +import com.juu.juulabel.common.exception.code.ErrorCode; + +import lombok.Getter; + +@Getter +public class CustomPasetoException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomPasetoException(String message) { + super(message); + this.errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + } + + public CustomPasetoException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomPasetoException(String message, ErrorCode errorCode) { + super(message); + this.errorCode = 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 0b50a13..89bdee9 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 @@ -23,6 +23,13 @@ public enum ErrorCode { INVALID_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "인증이 올바르지 않습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), + /** + * CSRF Security + */ + CSRF_TOKEN_INVALID(HttpStatus.FORBIDDEN, "CSRF 토큰이 유효하지 않습니다."), + CSRF_TOKEN_MISSING(HttpStatus.FORBIDDEN, "CSRF 토큰이 누락되었습니다."), + CSRF_TOKEN_MISMATCH(HttpStatus.FORBIDDEN, "CSRF 토큰이 일치하지 않습니다."), + /** * Json Web Token */ @@ -31,6 +38,16 @@ public enum ErrorCode { JWT_MALFORMED_EXCEPTION(HttpStatus.UNAUTHORIZED, "잘못된 형식의 토큰입니다."), JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), + /** + * Paseto + */ + PAS_EXPIRED_EXCEPTION(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + PAS_UNSUPPORTED_EXCEPTION(HttpStatus.BAD_REQUEST, "지원되지 않는 토큰입니다."), + PAS_MALFORMED_EXCEPTION(HttpStatus.UNAUTHORIZED, "잘못된 형식의 토큰입니다."), + PAS_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), + PAS_SECURITY_EXCEPTION(HttpStatus.UNAUTHORIZED, "보안 오류가 발생하였습니다."), + PAS_IO_EXCEPTION(HttpStatus.BAD_REQUEST, "IO 오류가 발생하였습니다."), + /** * Authorization * Authorization @@ -38,18 +55,19 @@ public enum ErrorCode { DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "헤더에 Device-Id가 누락되었습니다."), OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 로그인 경로를 찾을 수 없습니다."), + SIGN_UP_SESSION_EXPIRED(HttpStatus.BAD_REQUEST, "회원가입 세션이 만료되었습니다."), + /** - * AuthException + * User Session */ - REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "토큰을 찾을 수 없습니다."), - REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용 감지"), + USER_SESSION_EXPIRED(HttpStatus.BAD_REQUEST, "세션이 만료되었습니다."), + USER_SESSION_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "세션 재사용 감지"), - SOCIAL_LINK_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 링크를 찾을 수 없습니다."), - SOCIAL_LINK_ALREADY_USED(HttpStatus.BAD_REQUEST, "소셜 링크가 이미 사용되었습니다."), PROVIDER_ID_MISMATCH(HttpStatus.FORBIDDEN, "소셜 아이디 불일치"), DEVICE_ID_MISMATCH(HttpStatus.FORBIDDEN, "Device-Id 불일치"), USER_AGENT_MISMATCH(HttpStatus.FORBIDDEN, "User-Agent 불일치"), - HIGH_SECURITY_RISK(HttpStatus.FORBIDDEN, "높은 보안 위협이 감지되었습니다."), + HIGH_SECURITY_RISK(HttpStatus.FORBIDDEN, "높은 보안 위협이 감지되었습니다."), + /** * Admin, Member */ @@ -58,6 +76,7 @@ public enum ErrorCode { MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "회원 정보를 찾을 수 없습니다."), MEMBER_EMAIL_DUPLICATE(HttpStatus.BAD_REQUEST, "중복된 이메일입니다."), MEMBER_NICKNAME_DUPLICATE(HttpStatus.BAD_REQUEST, "중복된 닉네임입니다."), + MEMBER_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "활성화되지 않은 회원입니다."), /** * TERMS 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 b6a0b7e..40a164e 100644 --- a/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java +++ b/src/main/java/com/juu/juulabel/common/factory/OAuthProviderFactory.java @@ -1,46 +1,44 @@ package com.juu.juulabel.common.factory; +import com.juu.juulabel.common.provider.oauth.AppleProvider; 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; @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 final AppleProvider appleProvider; private OAuthProvider getOAuthProvider(Provider provider) { return switch (provider) { case KAKAO -> kakaoProvider; case GOOGLE -> googleProvider; + case APPLE -> appleProvider; default -> throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); }; } - public OAuthUser getOAuthUser(OAuthLoginRequest request) { - - Provider provider = request.provider(); - String accessToken = getOAuthProvider(provider) - .getOAuthToken(request.redirectUri(), request.code()) - .accessToken(); + public OAuthUser getOAuthUser(Provider provider, String code, String redirectUrl) { + + OAuthToken oauthToken = getOAuthProvider(provider) + .getOAuthToken(redirectUrl, code); return getOAuthProvider(provider) - .getOAuthUser(accessToken); + .getOAuthUser(oauthToken); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java b/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java new file mode 100644 index 0000000..ea56cac --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java @@ -0,0 +1,40 @@ +package com.juu.juulabel.common.filter; + +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.SecurityResponseUtil; +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.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthExceptionFilter extends OncePerRequestFilter { + + private final SecurityResponseUtil securityResponseUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (AuthenticationException ex) { + log.warn("Authentication failed for request {}: {}", request.getRequestURI(), ex.getMessage()); + securityResponseUtil.setErrorResponse(response, HttpStatus.BAD_REQUEST, ex); + } catch (Exception ex) { + log.error("Unexpected exception in auth filter for request {}: {}", + request.getRequestURI(), ex.getMessage()); + securityResponseUtil.setErrorResponse(response, HttpStatus.UNAUTHORIZED, + ErrorCode.INVALID_AUTHENTICATION, ex.getMessage()); + } + } +} \ 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/AuthorizationFilter.java similarity index 50% rename from src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java rename to src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java index 62eb660..25742c0 100644 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java @@ -1,13 +1,14 @@ package com.juu.juulabel.common.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.juu.juulabel.common.constants.AuthConstants; 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.util.CookieUtil; +import com.juu.juulabel.common.provider.token.paseto.SignupTokenProvider; import com.juu.juulabel.common.response.CommonResponse; import com.juu.juulabel.common.util.HttpRequestUtil; +import com.juu.juulabel.redis.SessionManager; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -15,49 +16,43 @@ 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; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.List; @Slf4j @Component @RequiredArgsConstructor -public class JwtAuthorizationFilter extends OncePerRequestFilter { +public class AuthorizationFilter extends OncePerRequestFilter { - 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"; + private static final List ALLOWED_METHODS = List.of("OPTIONS"); + + private final SignupTokenProvider signUpTokenProvider; + private final SessionManager sessionManager; + private final CookieUtil cookieUtil; + private final ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { - String authHeader = extractAuthorizationHeader(request); - - if (authHeader != null) { - if (isSignUpRequest()) { - processSignUpToken(authHeader); - } else { - processAccessToken(authHeader); - } + if (isSignUpRequest()) { + handleSignUpRequest(); } else { - throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); + if (!ALLOWED_METHODS.contains(request.getMethod())) { + handleRegularRequest(); + } } - - } catch (CustomJwtException e) { - handleJwtException(response, e); - return; } catch (AuthException e) { handleAuthException(response, e); return; @@ -66,65 +61,44 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } - /** - * 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); + private void handleSignUpRequest() { + String signupToken = cookieUtil.getCookie(AuthConstants.SIGN_UP_TOKEN_NAME); + + if (!StringUtils.hasText(signupToken)) { + throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); + } - Authentication authentication = signUpTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); + processSignUpToken(signupToken); + } + + private void handleRegularRequest() { + String authToken = cookieUtil.getCookie(AuthConstants.AUTH_TOKEN_NAME); - } catch (Exception e) { - log.error("Unexpected error in sign-up token processing", e); - throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + if (StringUtils.hasText(authToken)) { + processUserSession(authToken); } } - /** - * Process access token with validation - */ - private void processAccessToken(String authHeader) { - String token = accessTokenProvider.resolveToken(authHeader); - Authentication authentication = accessTokenProvider.getAuthentication(token); + private void processSignUpToken(String signupToken) { + String token = signUpTokenProvider.resolveToken(signupToken); + Authentication authentication = signUpTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); } - /** - * 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()); + private void processUserSession(String authToken) { + Authentication authentication = sessionManager.getAuthentication(authToken); + SecurityContextHolder.getContext().setAuthentication(authentication); } - /** - * 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); diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtExceptionFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtExceptionFilter.java deleted file mode 100644 index 1bfc5bd..0000000 --- a/src/main/java/com/juu/juulabel/common/filter/JwtExceptionFilter.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.juu.juulabel.common.filter; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.juu.juulabel.common.exception.CustomJwtException; -import com.juu.juulabel.common.response.CommonResponse; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.security.core.AuthenticationException; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Component -@RequiredArgsConstructor -public class JwtExceptionFilter extends OncePerRequestFilter { - - private final ObjectMapper objectMapper; - private static final String UTF_8 = "UTF-8"; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - try { - filterChain.doFilter(request, response); - } catch (AuthenticationException ex) { - setErrorResponse(response, HttpStatus.BAD_REQUEST, - objectMapper.writeValueAsString(CommonResponse.fail(ex))); - } catch (CustomJwtException ex) { - setErrorResponse( - response, - ex.getErrorCode().getHttpStatus(), - objectMapper.writeValueAsString(CommonResponse.fail(ex.getErrorCode(), ex.getMessage()))); - } - } - - private void setErrorResponse(HttpServletResponse response, HttpStatus status, String body) throws IOException { - response.setCharacterEncoding(UTF_8); - response.setStatus(status.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write(body); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java b/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..f1e4b11 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,63 @@ +package com.juu.juulabel.common.handler; + +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.SecurityResponseUtil; +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.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.csrf.InvalidCsrfTokenException; +import org.springframework.security.web.csrf.MissingCsrfTokenException; +import org.springframework.security.web.csrf.CsrfException; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final SecurityResponseUtil securityResponseUtil; + + // Map exception types to error codes for better performance + private static final Map, ErrorCode> CSRF_ERROR_MAP = Map.of( + InvalidCsrfTokenException.class, ErrorCode.CSRF_TOKEN_INVALID, + MissingCsrfTokenException.class, ErrorCode.CSRF_TOKEN_MISSING, + CsrfException.class, ErrorCode.CSRF_TOKEN_MISMATCH); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + + String requestInfo = String.format("%s %s", request.getMethod(), request.getRequestURI()); + log.warn("Access denied for request: {} - Exception: {}", + requestInfo, accessDeniedException.getClass().getSimpleName()); + + // Handle CSRF exceptions with optimized lookup + ErrorCode csrfErrorCode = CSRF_ERROR_MAP.get(accessDeniedException.getClass()); + if (csrfErrorCode != null) { + handleCsrfException(response, csrfErrorCode, accessDeniedException.getMessage(), requestInfo); + } else { + handleAccessDenied(response, accessDeniedException.getMessage(), requestInfo); + } + } + + private void handleCsrfException(HttpServletResponse response, ErrorCode errorCode, + String message, String requestInfo) throws IOException { + log.warn("CSRF token validation failed for {}: {}", requestInfo, message); + securityResponseUtil.setErrorResponse(response, HttpStatus.FORBIDDEN, errorCode, message); + } + + private void handleAccessDenied(HttpServletResponse response, String message, + String requestInfo) throws IOException { + log.warn("Access denied for {}: {}", requestInfo, message); + securityResponseUtil.setErrorResponse(response, HttpStatus.FORBIDDEN, + ErrorCode.INVALID_AUTHENTICATION, message); + } +} \ 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 index 27cdeb9..ad15f2b 100644 --- a/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java +++ b/src/main/java/com/juu/juulabel/common/properties/CookieProperties.java @@ -28,9 +28,9 @@ public class CookieProperties { /** * Default path for cookies. - * Default: /app + * Default: / */ -private String path = "/app"; + private String path = "/"; /** * Default SameSite attribute for secure cookies. diff --git a/src/main/java/com/juu/juulabel/common/properties/RedirectProperties.java b/src/main/java/com/juu/juulabel/common/properties/RedirectProperties.java new file mode 100644 index 0000000..5c26faf --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/properties/RedirectProperties.java @@ -0,0 +1,38 @@ +package com.juu.juulabel.common.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Data; + +import com.juu.juulabel.member.domain.Provider; + +@Data +@Component +@ConfigurationProperties(prefix = "app.redirect") +public class RedirectProperties { + + private String baseServer; + private String baseClient; + private String callback; + private String login; + private String signup; + private String error; + + public String getRedirectUrl(Provider provider) { + return baseServer + callback + "/" + provider.name().toLowerCase(); + } + + public String getLoginUrl() { + return baseClient + login; + } + + public String getSignupUrl() { + return baseClient + signup; + } + + public String getErrorUrl() { + return baseClient + error; + } + +} 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 deleted file mode 100644 index b694d43..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/AccessTokenProvider.java +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 11b6324..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 3f63168..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/MemberTokenProvider.java +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 38fba8d..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/RefreshTokenProvider.java +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 427d92a..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/jwt/SignupTokenProvider.java +++ /dev/null @@ -1,91 +0,0 @@ -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/provider/oauth/AppleProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java new file mode 100644 index 0000000..ee60bde --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java @@ -0,0 +1,49 @@ +package com.juu.juulabel.common.provider.oauth; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.client.AppleAuthClient; +import com.juu.juulabel.common.provider.token.jwt.AppleTokenProvider; +import com.juu.juulabel.member.request.ApplePublicKey; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.token.OAuthToken; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AppleProvider implements OAuthProvider { + + private final AppleAuthClient appleAuthClient; + private final AppleTokenProvider appleTokenProvider; + + @Value("${spring.security.oauth2.client.registration.apple.authorization-grant-type}") + private String grantType; + + @Value("${spring.security.oauth2.client.registration.apple.clientId}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.apple.clientSecret}") + private String clientSecret; + + @Override + public OAuthToken getOAuthToken(String redirectUri, String code) { + return appleAuthClient.generateOAuthToken( + code, + clientId, + clientSecret, + redirectUri, + grantType); + } + + @Override + public OAuthUser getOAuthUser(OAuthToken oauthToken) { + List publicKeys = appleAuthClient.getApplePublicKeys(); + + return appleTokenProvider.getAppleUserFromToken(publicKeys, oauthToken); + } + +} diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java index b36ed11..ec8897a 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java @@ -2,7 +2,6 @@ import com.juu.juulabel.common.client.GoogleApiClient; import com.juu.juulabel.common.client.GoogleAuthClient; -import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; import lombok.RequiredArgsConstructor; @@ -32,21 +31,18 @@ public class GoogleProvider implements OAuthProvider { public OAuthToken getOAuthToken(String redirectUri, String code) { String decodedCode = URLDecoder.decode(code, StandardCharsets.UTF_8); // 구글 oauth 서버로부터 받은 인가코드는 디코딩 해줘야 함 return googleAuthClient.generateOAuthToken( - decodedCode, - clientId, - clientSecret, - redirectUri, - grantType - ); + decodedCode, + clientId, + clientSecret, + redirectUri, + grantType); } @Override - public OAuthUser getOAuthUser(String accessToken) { - return googleApiClient.getUserInfo(getBearerToken(accessToken)); - } - - private String getBearerToken(String accessToken) { - return AuthConstants.TOKEN_PREFIX + accessToken; + public OAuthUser getOAuthUser(OAuthToken oauthToken) { + String accessToken = getBearerToken(oauthToken.accessToken()); + System.out.println("accessToken: " + accessToken); + return googleApiClient.getUserInfo(accessToken); } } diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java index 8cf3a09..35aca3f 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/KakaoProvider.java @@ -2,7 +2,6 @@ import com.juu.juulabel.common.client.KakaoApiClient; import com.juu.juulabel.common.client.KakaoAuthClient; -import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; import lombok.RequiredArgsConstructor; @@ -32,17 +31,12 @@ public OAuthToken getOAuthToken(String redirectUri, String code) { clientId, redirectUri, code, - clientSecret - ); + clientSecret); } @Override - public OAuthUser getOAuthUser(String accessToken) { - return kakaoApiClient.getUserInfo(getBearerToken(accessToken)); - } - - private String getBearerToken(String accessToken) { - return AuthConstants.TOKEN_PREFIX + accessToken; + public OAuthUser getOAuthUser(OAuthToken oauthToken) { + return kakaoApiClient.getUserInfo(getBearerToken(oauthToken.accessToken())); } } diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java index 2176c0f..8296b92 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/OAuthProvider.java @@ -1,5 +1,6 @@ package com.juu.juulabel.common.provider.oauth; +import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; @@ -7,5 +8,9 @@ public interface OAuthProvider { OAuthToken getOAuthToken(String redirectUri, String code); - OAuthUser getOAuthUser(String accessToken); + OAuthUser getOAuthUser(OAuthToken oauthToken); + + default String getBearerToken(String accessToken) { + return AuthConstants.TOKEN_PREFIX + accessToken; + } } diff --git a/src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java new file mode 100644 index 0000000..f141433 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java @@ -0,0 +1,31 @@ +package com.juu.juulabel.common.provider.token; + +import java.time.Duration; +import java.util.function.Function; + +import org.springframework.util.StringUtils; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.exception.InvalidParamException; + +public abstract class TokenProvider { + + public static final String ISSUER = "juulabel.com"; + protected final Duration duration; + + protected TokenProvider(Duration duration) { + this.duration = duration; + } + + public String resolveToken(String header) { + if (!StringUtils.hasText(header)) { + throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); + } + return header.replace(AuthConstants.TOKEN_PREFIX, ""); + } + + public abstract F extractFromClaims(String token, Function claimsResolver); + + public abstract T parseClaims(String token); +} diff --git a/src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java new file mode 100644 index 0000000..870650f --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java @@ -0,0 +1,154 @@ +package com.juu.juulabel.common.provider.token.jwt; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juu.juulabel.common.exception.CustomJwtException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.request.ApplePublicKey; +import com.juu.juulabel.member.request.AppleUser; +import com.juu.juulabel.member.token.OAuthToken; + +import io.jsonwebtoken.Jwts; + +@Component +public class AppleTokenProvider extends JwtTokenProvider { + + // Constants for better maintainability + private static final String RSA_ALGORITHM = "RSA"; + private static final String KID_HEADER_FIELD = "kid"; + private static final String SUB_CLAIM = "sub"; + private static final String EMAIL_CLAIM = "email"; + private static final String JWT_SEPARATOR = "\\."; + private static final int HEADER_INDEX = 0; + private static final int EXPECTED_JWT_PARTS = 3; + + // Reuse ObjectMapper instance for better performance + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public AppleTokenProvider() { + super(null, null); + } + + /** + * Extracts Apple user information from JWT token using the provided public + * keys. + * + * @param publicKeys List of Apple's public keys + * @param oauthToken OAuth token containing the ID token + * @return AppleUser with extracted user information + * @throws CustomJwtException if token processing fails + */ + public AppleUser getAppleUserFromToken(List publicKeys, OAuthToken oauthToken) { + + ApplePublicKey applePublicKey = getApplePublicKey(publicKeys, oauthToken); + PublicKey publicKey = buildPublicKey(applePublicKey); + + // Set up JWT parser with the public key + super.key = publicKey; + super.jwtParser = Jwts.parser().verifyWith(publicKey).build(); + + return extractFromClaims(oauthToken.idToken(), claims -> new AppleUser( + claims.get(SUB_CLAIM, String.class), + claims.get(EMAIL_CLAIM, String.class))); + } + + /** + * Finds the matching Apple public key based on the 'kid' in the JWT header. + * + * @param publicKeys List of available public keys + * @param oauthToken OAuth token containing the ID token + * @return Matching ApplePublicKey + * @throws CustomJwtException if no matching key is found or JWT processing + * fails + */ + private ApplePublicKey getApplePublicKey(List publicKeys, OAuthToken oauthToken) { + String kid = extractKidFromToken(oauthToken.idToken()); + + return publicKeys.stream() + .filter(key -> kid.equals(key.kid())) + .findFirst() + .orElseThrow(() -> new CustomJwtException( + String.format("No matching Apple public key found for kid: %s", kid), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION)); + } + + /** + * Extracts the 'kid' (Key ID) from the JWT token header. + * + * @param idToken JWT ID token + * @return Key ID string + * @throws CustomJwtException if token parsing fails + */ + private String extractKidFromToken(String idToken) { + try { + String[] chunks = idToken.split(JWT_SEPARATOR); + if (chunks.length != EXPECTED_JWT_PARTS) { + throw new CustomJwtException("Invalid JWT format: expected 3 parts separated by dots", + ErrorCode.JWT_MALFORMED_EXCEPTION); + } + + byte[] headerBytes = Base64.getUrlDecoder().decode(chunks[HEADER_INDEX]); + String header = new String(headerBytes, StandardCharsets.UTF_8); + JsonNode headerNode = OBJECT_MAPPER.readTree(header); + + JsonNode kidNode = headerNode.get(KID_HEADER_FIELD); + if (kidNode == null || kidNode.isNull()) { + throw new CustomJwtException("Missing 'kid' field in JWT header", + ErrorCode.JWT_MALFORMED_EXCEPTION); + } + + return kidNode.asText(); + } catch (IllegalArgumentException e) { + throw new CustomJwtException("Failed to decode JWT header: invalid Base64 encoding - " + e.getMessage(), + ErrorCode.JWT_MALFORMED_EXCEPTION); + } catch (JsonProcessingException e) { + throw new CustomJwtException("Failed to parse JWT header as JSON - " + e.getMessage(), + ErrorCode.JWT_MALFORMED_EXCEPTION); + } + } + + /** + * Builds RSA public key from Apple's public key data. + * + * @param applePublicKey Apple public key containing modulus and exponent + * @return RSA PublicKey instance + * @throws CustomJwtException if key construction fails + */ + private PublicKey buildPublicKey(ApplePublicKey applePublicKey) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); + + BigInteger modulus = new BigInteger(1, nBytes); + BigInteger exponent = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(modulus, exponent); + KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM); + return keyFactory.generatePublic(publicKeySpec); + + } catch (IllegalArgumentException e) { + throw new CustomJwtException("Invalid Base64 encoding in Apple public key - " + e.getMessage(), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } catch (NoSuchAlgorithmException e) { + throw new CustomJwtException("RSA algorithm not available - " + e.getMessage(), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } catch (InvalidKeySpecException e) { + throw new CustomJwtException("Invalid RSA key specification - " + e.getMessage(), + ErrorCode.JWT_UNSUPPORTED_EXCEPTION); + } + } + +} diff --git a/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..8adb8b6 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java @@ -0,0 +1,65 @@ +package com.juu.juulabel.common.provider.token.jwt; + +import com.juu.juulabel.common.exception.CustomJwtException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.token.TokenProvider; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; + +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.security.Key; +import java.time.Duration; +import java.util.Date; +import java.util.Map; +import java.util.function.Function; + +import javax.crypto.SecretKey; + +@Component +public abstract class JwtTokenProvider extends TokenProvider { + + protected Key key; + protected JwtParser jwtParser; + + protected JwtTokenProvider(String secretKey, Duration duration) { + super(duration); + this.key = secretKey != null ? Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)) : null; + this.jwtParser = this.key != null ? Jwts.parser().verifyWith((SecretKey) this.key).build() : null; + } + + public JwtBuilder build(Map claims) { + return Jwts.builder() + .claims(claims) + .issuer(ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + this.duration.toMillis())) + // .audience(ISSUER) + .signWith(key); + } + + @Override + public T extractFromClaims(String token, Function claimsResolver) { + return claimsResolver.apply(parseClaims(token)); + } + + @Override + public 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/token/paseto/PasetoTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java new file mode 100644 index 0000000..4178b48 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java @@ -0,0 +1,69 @@ +package com.juu.juulabel.common.provider.token.paseto; + +import java.time.Duration; +import java.time.Instant; +import java.util.function.Function; + +import javax.crypto.SecretKey; + +import com.juu.juulabel.common.exception.CustomPasetoException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.token.TokenProvider; + +import dev.paseto.jpaseto.Pasetos; +import dev.paseto.jpaseto.PasetoParser; +import dev.paseto.jpaseto.Claims; +import dev.paseto.jpaseto.PasetoIOException; +import dev.paseto.jpaseto.PasetoException; +import dev.paseto.jpaseto.PasetoKeyException; +import dev.paseto.jpaseto.PasetoSignatureException; +import dev.paseto.jpaseto.PasetoV2LocalBuilder; +import dev.paseto.jpaseto.ExpiredPasetoException; +import dev.paseto.jpaseto.MissingClaimException; +import dev.paseto.jpaseto.IncorrectClaimException; +import dev.paseto.jpaseto.RequiredTypeException; +import dev.paseto.jpaseto.lang.Keys; + +public abstract class PasetoTokenProvider extends TokenProvider { + + protected final PasetoParser parser; + protected final SecretKey key; + + protected PasetoTokenProvider(String secretKey, Duration duration) { + super(duration); + this.key = Keys.secretKey(secretKey.getBytes()); + this.parser = Pasetos.parserBuilder() + .setSharedSecret(this.key) + .build(); + } + + protected PasetoV2LocalBuilder builder() { + return Pasetos.V2.LOCAL.builder() + .setSharedSecret(this.key) + .setIssuer(ISSUER) + .setAudience("juu-label-client") + .setIssuedAt(Instant.now()) + .setExpiration(Instant.now().plus(this.duration)); + } + + @Override + public T extractFromClaims(String token, Function claimsResolver) { + return claimsResolver.apply(parseClaims(token)); + } + + public Claims parseClaims(String token) { + try { + return parser.parse(token).getClaims(); + } catch (ExpiredPasetoException e) { + throw new CustomPasetoException(ErrorCode.PAS_EXPIRED_EXCEPTION); + } catch (PasetoSignatureException | PasetoKeyException e) { + throw new CustomPasetoException(ErrorCode.PAS_SECURITY_EXCEPTION); + } catch (PasetoIOException e) { + throw new CustomPasetoException(ErrorCode.PAS_IO_EXCEPTION); + } catch (MissingClaimException | IncorrectClaimException | RequiredTypeException e) { + throw new CustomPasetoException(ErrorCode.PAS_ILLEGAL_ARGUMENT_EXCEPTION); + } catch (PasetoException e) { + throw new CustomPasetoException(ErrorCode.PAS_UNSUPPORTED_EXCEPTION); + } + } +} diff --git a/src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java new file mode 100644 index 0000000..854f2e5 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java @@ -0,0 +1,128 @@ +package com.juu.juulabel.common.provider.token.paseto; + +import java.util.Collections; + +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.common.constants.AuthConstants; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.CookieUtil; + +import dev.paseto.jpaseto.Claims; + +@Component +public class SignupTokenProvider extends PasetoTokenProvider { + + private static final String AUDIENCE_CLAIM = "user-signup-completion"; + private static final String EMAIL_CLAIM = "email"; + private static final String PROVIDER_CLAIM = "provider"; + private static final String PROVIDER_ID_CLAIM = "providerId"; + private static final String NONCE_CLAIM = "nonce"; + private static final String AUDIENCE_CLAIM_KEY = "aud"; + + private final MemberReader memberReader; + private final CookieUtil cookieUtil; + + public SignupTokenProvider(@Value("${spring.jwt.signup-key}") String secretKey, MemberReader memberReader, + CookieUtil cookieUtil) { + super(secretKey, AuthConstants.SIGN_UP_TOKEN_DURATION); + this.memberReader = memberReader; + this.cookieUtil = cookieUtil; + } + + public void createToken(OAuthUser oAuthUser, String nonce) { + String token = builder() + .claim(EMAIL_CLAIM, oAuthUser.email()) + .claim(PROVIDER_CLAIM, oAuthUser.provider().name()) + .claim(PROVIDER_ID_CLAIM, oAuthUser.id()) + .claim(NONCE_CLAIM, nonce) + .claim(AUDIENCE_CLAIM_KEY, AUDIENCE_CLAIM) + .compact(); + cookieUtil.addCookie(AuthConstants.SIGN_UP_TOKEN_NAME, token, + (int) AuthConstants.SIGN_UP_TOKEN_DURATION.toSeconds()); + } + + public Authentication getAuthentication(String token) { + Member member = verifyToken(token); + return new UsernamePasswordAuthenticationToken(member, null, Collections.emptyList()); + } + + public Member verifyToken(String token) { + Claims claims = parseClaims(token); + + // Extract and validate all claims at once + TokenClaims tokenClaims = extractTokenClaims(claims); + + // Validate audience first (fast check) + if (!AUDIENCE_CLAIM.equals(tokenClaims.audience())) { + throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); + } + + // Get member and validate + Member member = memberReader.getByEmail(tokenClaims.email()); + validateMemberAgainstToken(member, tokenClaims); + + return member; + } + + private TokenClaims extractTokenClaims(Claims claims) { + try { + return new TokenClaims( + getRequiredClaimAsString(claims, EMAIL_CLAIM), + Provider.valueOf(getRequiredClaimAsString(claims, PROVIDER_CLAIM)), + getRequiredClaimAsString(claims, PROVIDER_ID_CLAIM), + getRequiredClaimAsString(claims, NONCE_CLAIM), + getRequiredClaimAsString(claims, AUDIENCE_CLAIM_KEY)); + } catch (IllegalArgumentException e) { + throw new AuthException("Invalid provider in token", ErrorCode.INVALID_AUTHENTICATION); + } + } + + private void validateMemberAgainstToken(Member member, TokenClaims tokenClaims) { + // Check provider and provider ID + if (member.getProvider() != tokenClaims.provider()) { + throw new AuthException("Provider mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + + if (!member.getProviderId().equals(tokenClaims.providerId())) { + throw new AuthException("Provider ID mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + + if (!member.getNickname().equals(tokenClaims.nonce())) { + throw new AuthException("Token validation failed", ErrorCode.INVALID_AUTHENTICATION); + } + + // Check member status + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member already completed signup", ErrorCode.INVALID_AUTHENTICATION); + } + } + + private String getRequiredClaimAsString(Claims claims, String claimName) { + Object claimValue = claims.get(claimName); + if (claimValue == null) { + throw new AuthException("Missing required claim: " + claimName, ErrorCode.INVALID_AUTHENTICATION); + } + return claimValue.toString(); + } + + /** + * Record to hold extracted token claims for better type safety and performance + */ + private record TokenClaims( + String email, + Provider provider, + String providerId, + String nonce, + String audience) { + } +} diff --git a/src/main/java/com/juu/juulabel/common/util/HashingUtil.java b/src/main/java/com/juu/juulabel/common/util/HashingUtil.java deleted file mode 100644 index 8353baf..0000000 --- a/src/main/java/com/juu/juulabel/common/util/HashingUtil.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.juu.juulabel.common.util; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; - -public final class HashingUtil { - - private HashingUtil() { - throw new AssertionError("Utility class should not be instantiated"); - } - - public static String hashSha256(String input) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashedBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(hashedBytes); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } - } -} diff --git a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java index 34de980..c214b43 100644 --- a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java +++ b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java @@ -33,6 +33,10 @@ public static String getAuthorization() { public static String getDeviceId() { HttpServletRequest request = getCurrentRequest(); String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); + if (deviceId == null || deviceId.trim().isEmpty()) { + deviceId = request.getParameter("state"); + } + if (deviceId == null || deviceId.trim().isEmpty()) { throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); } @@ -49,4 +53,5 @@ 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 new file mode 100644 index 0000000..c64509c --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java @@ -0,0 +1,41 @@ +package com.juu.juulabel.common.util; + +import java.io.IOException; + +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.properties.RedirectProperties; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class HttpResponseUtil extends AbstractHttpUtil { + + private final RedirectProperties redirectProperties; + + public void redirectToLogin() { + redirect(redirectProperties.getLoginUrl()); + } + + public void redirectToSignup() { + redirect(redirectProperties.getSignupUrl()); + } + + public void redirectToError() { + redirect(redirectProperties.getErrorUrl()); + } + + private void redirect(String url) { + try { + HttpServletResponse response = getCurrentResponse(); + response.sendRedirect(url); + } catch (IOException e) { + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/src/main/java/com/juu/juulabel/common/util/SecurityResponseUtil.java b/src/main/java/com/juu/juulabel/common/util/SecurityResponseUtil.java new file mode 100644 index 0000000..6609b36 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/SecurityResponseUtil.java @@ -0,0 +1,57 @@ +package com.juu.juulabel.common.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.response.CommonResponse; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class SecurityResponseUtil { + + private final ObjectMapper objectMapper; + private static final String UTF_8 = "UTF-8"; + + /** + * Sets standardized error response for security-related exceptions + */ + public void setErrorResponse(HttpServletResponse response, HttpStatus status, + ErrorCode errorCode, String message) throws IOException { + String responseBody = objectMapper.writeValueAsString( + CommonResponse.fail(errorCode, message)); + setResponse(response, status, responseBody); + } + + /** + * Sets standardized error response with default error message + */ + public void setErrorResponse(HttpServletResponse response, HttpStatus status, + ErrorCode errorCode) throws IOException { + String responseBody = objectMapper.writeValueAsString( + CommonResponse.fail(errorCode)); + setResponse(response, status, responseBody); + } + + /** + * Sets standardized error response for runtime exceptions + */ + public void setErrorResponse(HttpServletResponse response, HttpStatus status, + RuntimeException exception) throws IOException { + String responseBody = objectMapper.writeValueAsString( + CommonResponse.fail(exception)); + setResponse(response, status, responseBody); + } + + private void setResponse(HttpServletResponse response, HttpStatus status, String body) throws IOException { + response.setCharacterEncoding(UTF_8); + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(body); + } +} \ 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 dc7e71e..cb020fd 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Member.java +++ b/src/main/java/com/juu/juulabel/member/domain/Member.java @@ -6,7 +6,7 @@ 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.member.token.SignUpToken; import com.juu.juulabel.common.base.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -77,16 +77,22 @@ public class Member extends BaseTimeEntity { @Column(name = "deleted_at", columnDefinition = "datetime comment '탈퇴 일시'") private LocalDateTime deletedAt; - public static Member create(SignUpMemberRequest signUpMemberRequest, SignUpToken signUpToken) { + public void completeSignUp(SignUpMemberRequest signUpMemberRequest) { + this.nickname = signUpMemberRequest.nickname(); + this.gender = signUpMemberRequest.gender(); + this.status = MemberStatus.ACTIVE; + } + + public static Member create(OAuthUser oAuthUser, String nonce) { return Member.builder() - .email(signUpToken.email()) - .nickname(signUpMemberRequest.nickname()) - .gender(signUpMemberRequest.gender()) - .provider(signUpToken.provider()) - .providerId(signUpToken.providerId()) - .status(MemberStatus.ACTIVE) + .email(oAuthUser.email()) + .nickname(nonce) + .gender(Gender.NONE) + .provider(oAuthUser.provider()) + .providerId(oAuthUser.id()) .hasBadge(false) - .role(MemberRole.ROLE_USER) + .role(MemberRole.ROLE_GUEST) + .status(MemberStatus.PENDING) .build(); } @@ -110,6 +116,11 @@ public void validateLoginMember(OAuthUser oAuthUser) { if (this.deletedAt != null) { throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); } + + if (this.status == MemberStatus.INACTIVE ) { + throw new BaseException(ErrorCode.MEMBER_NOT_ACTIVE); + } + if (!this.provider.equals(oAuthUser.provider())) { throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); } diff --git a/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java b/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java index 5f779a3..82d77e4 100644 --- a/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java +++ b/src/main/java/com/juu/juulabel/member/domain/MemberStatus.java @@ -4,5 +4,5 @@ @Getter public enum MemberStatus { - ACTIVE, INACTIVE, WITHDRAWAL, BLOCKED + ACTIVE, INACTIVE, PENDING, WITHDRAWAL, BLOCKED } 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 d92b9e1..012de26 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Provider.java +++ b/src/main/java/com/juu/juulabel/member/domain/Provider.java @@ -2,5 +2,6 @@ public enum Provider { GOOGLE, - KAKAO; + KAKAO, + APPLE; } \ No newline at end of file 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 0caabdc..6b64526 100644 --- a/src/main/java/com/juu/juulabel/member/repository/MemberReader.java +++ b/src/main/java/com/juu/juulabel/member/repository/MemberReader.java @@ -8,6 +8,7 @@ import com.juu.juulabel.member.repository.jpa.MemberJpaRepository; import com.juu.juulabel.member.repository.jpa.MemberQueryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import java.util.List; import java.util.Optional; @@ -24,6 +25,7 @@ public Member getById(final Long id) { .orElseThrow(() -> new InvalidParamException(ErrorCode.MEMBER_NOT_FOUND)); } + @Cacheable(value = "member", key = "#email") public Member getByEmail(String email) { return memberJpaRepository.findByEmail(email) .orElseThrow(() -> new InvalidParamException(ErrorCode.MEMBER_NOT_FOUND)); diff --git a/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java b/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java index b755422..b274e3f 100644 --- a/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java +++ b/src/main/java/com/juu/juulabel/member/repository/jpa/MemberJpaRepository.java @@ -1,9 +1,11 @@ package com.juu.juulabel.member.repository.jpa; - import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.domain.MemberStatus; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; @@ -13,7 +15,11 @@ public interface MemberJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long memberId); + @Query("SELECT m FROM Member m WHERE m.email = :email AND m.status != :status") + Optional findByEmailAndStatusNot(String email, MemberStatus status); + boolean existsByEmailAndProvider(String email, Provider provider); boolean existsByNickname(String nickname); + } diff --git a/src/main/java/com/juu/juulabel/member/request/ApplePublicKey.java b/src/main/java/com/juu/juulabel/member/request/ApplePublicKey.java new file mode 100644 index 0000000..207f8c0 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/request/ApplePublicKey.java @@ -0,0 +1,11 @@ +package com.juu.juulabel.member.request; + +public record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e) { + +} diff --git a/src/main/java/com/juu/juulabel/member/request/AppleUser.java b/src/main/java/com/juu/juulabel/member/request/AppleUser.java new file mode 100644 index 0000000..832a157 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/request/AppleUser.java @@ -0,0 +1,15 @@ +package com.juu.juulabel.member.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.juu.juulabel.member.domain.Provider; + +public record AppleUser( + @JsonProperty("id") String id, + @JsonProperty("email") String email) implements OAuthUser { + + @Override + public Provider provider() { + return Provider.APPLE; + } + +} 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 e4f854e..2862ba5 100644 --- a/src/main/java/com/juu/juulabel/member/request/KakaoUser.java +++ b/src/main/java/com/juu/juulabel/member/request/KakaoUser.java @@ -8,8 +8,7 @@ public record KakaoUser( @JsonProperty("has_signed_up") String hasSignedUp, @JsonProperty("connected_at") String connectedAt, @JsonProperty("synched_at") String synchedAt, - @JsonProperty("kakao_account") KakaoAccount kakaoAccount -) implements OAuthUser { + @JsonProperty("kakao_account") KakaoAccount kakaoAccount) implements OAuthUser { @Override public String email() { return kakaoAccount.email(); diff --git a/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java b/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java deleted file mode 100644 index 647ae47..0000000 --- a/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.juu.juulabel.member.request; - -import com.juu.juulabel.member.domain.Provider; - -public record OAuthUserInfo( - Long memberId, - String email, - String providerId, - Provider provider -) { -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/token/AppleToken.java b/src/main/java/com/juu/juulabel/member/token/AppleToken.java new file mode 100644 index 0000000..5a2e36d --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/token/AppleToken.java @@ -0,0 +1,21 @@ +package com.juu.juulabel.member.token; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AppleToken( + @JsonProperty("token_type") String tokenType, + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("id_token") String idToken) implements OAuthToken { + + @Override + public String scope() { + return null; + } + + @Override + public int refreshTokenExpiresIn() { + return 0; + } +} diff --git a/src/main/java/com/juu/juulabel/member/token/GoogleToken.java b/src/main/java/com/juu/juulabel/member/token/GoogleToken.java index a534238..414d0d0 100644 --- a/src/main/java/com/juu/juulabel/member/token/GoogleToken.java +++ b/src/main/java/com/juu/juulabel/member/token/GoogleToken.java @@ -3,13 +3,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; public record GoogleToken( - @JsonProperty("token_type") String tokenType, - @JsonProperty("access_token") String accessToken, - @JsonProperty("expires_in") int expiresIn, - @JsonProperty("refresh_token") String refreshToken, - @JsonProperty("scope") String scope, - @JsonProperty("id_token") String idToken -) implements OAuthToken { + @JsonProperty("token_type") String tokenType, + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("scope") String scope, + @JsonProperty("id_token") String idToken) implements OAuthToken { @Override public int refreshTokenExpiresIn() { return 0; diff --git a/src/main/java/com/juu/juulabel/member/token/KakaoToken.java b/src/main/java/com/juu/juulabel/member/token/KakaoToken.java index 03aac6a..b6bb322 100644 --- a/src/main/java/com/juu/juulabel/member/token/KakaoToken.java +++ b/src/main/java/com/juu/juulabel/member/token/KakaoToken.java @@ -3,11 +3,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; public record KakaoToken( - @JsonProperty("token_type") String tokenType, - @JsonProperty("access_token") String accessToken, - @JsonProperty("expires_in") int expiresIn, - @JsonProperty("refresh_token") String refreshToken, - @JsonProperty("refresh_token_expires_in") int refreshTokenExpiresIn, - @JsonProperty("scope") String scope -) implements OAuthToken { + @JsonProperty("token_type") String tokenType, + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") int expiresIn, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("refresh_token_expires_in") int refreshTokenExpiresIn, + @JsonProperty("scope") String scope) implements OAuthToken { + @Override + public String idToken() { + return null; + } } diff --git a/src/main/java/com/juu/juulabel/member/token/OAuthToken.java b/src/main/java/com/juu/juulabel/member/token/OAuthToken.java index fac5aea..aa13b4f 100644 --- a/src/main/java/com/juu/juulabel/member/token/OAuthToken.java +++ b/src/main/java/com/juu/juulabel/member/token/OAuthToken.java @@ -1,6 +1,7 @@ package com.juu.juulabel.member.token; public interface OAuthToken { + String idToken(); String tokenType(); String accessToken(); int expiresIn(); diff --git a/src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java b/src/main/java/com/juu/juulabel/member/token/SignUpToken.java similarity index 83% rename from src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java rename to src/main/java/com/juu/juulabel/member/token/SignUpToken.java index 275b572..3b68310 100644 --- a/src/main/java/com/juu/juulabel/auth/domain/SignUpToken.java +++ b/src/main/java/com/juu/juulabel/member/token/SignUpToken.java @@ -1,4 +1,4 @@ -package com.juu.juulabel.auth.domain; +package com.juu.juulabel.member.token; import com.juu.juulabel.member.domain.Provider; diff --git a/src/main/java/com/juu/juulabel/member/token/Token.java b/src/main/java/com/juu/juulabel/member/token/Token.java deleted file mode 100644 index 5ea3b24..0000000 --- a/src/main/java/com/juu/juulabel/member/token/Token.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.juu.juulabel.member.token; - -import java.util.Date; - -public record Token( - String accessToken, - Date accessExpiredAt -) { -} diff --git a/src/main/java/com/juu/juulabel/member/token/UserSession.java b/src/main/java/com/juu/juulabel/member/token/UserSession.java new file mode 100644 index 0000000..03494fb --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/token/UserSession.java @@ -0,0 +1,68 @@ +package com.juu.juulabel.member.token; + +import lombok.*; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +import com.juu.juulabel.common.constants.AuthConstants; +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.member.domain.MemberRole; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +// Remove timeToLive from @RedisHash since we'll use @TimeToLive field +@RedisHash(value = "user_session") +public class UserSession implements Serializable { + + @Id + private String id; + + @Indexed + private Long memberId; + + private String email; + + private MemberRole role; + + private String deviceId; + + private String ipAddress; + + private String userAgent; + + private LocalDateTime createdAt; + + private LocalDateTime lastAccessedAt; + + @TimeToLive + private Long ttl; + + @Builder + public UserSession(String id, Member member) { + final LocalDateTime now = LocalDateTime.now(); + this.id = id; + this.memberId = member.getId(); + this.email = member.getEmail(); + this.role = member.getRole(); + this.deviceId = HttpRequestUtil.getDeviceId(); + this.ipAddress = IpAddressExtractor.getClientIpAddress(); + this.userAgent = HttpRequestUtil.getUserAgent(); + this.createdAt = now; + this.lastAccessedAt = now; + this.ttl = (long) AuthConstants.USER_SESSION_TTL; // 7 days in seconds + } + + public void updateLastAccessed() { + this.lastAccessedAt = LocalDateTime.now(); + this.ttl = (long) AuthConstants.USER_SESSION_TTL; // Reset TTL to original value + } +} \ No newline at end of file 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 454d68c..91781fb 100644 --- a/src/main/java/com/juu/juulabel/member/util/MemberUtils.java +++ b/src/main/java/com/juu/juulabel/member/util/MemberUtils.java @@ -8,6 +8,7 @@ import java.util.List; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.exception.InvalidParamException; @@ -20,120 +21,191 @@ import com.juu.juulabel.terms.request.TermsAgreement; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.Map; -import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import java.util.Collections; /** * Member 관련 유틸리티 클래스 + * 회원 가입 시 추가 데이터 처리를 담당 */ +@Slf4j @Component @RequiredArgsConstructor public class MemberUtils { - /** - * 회원-주종 관계 목록 생성 - * - * @param member 회원 - * @param alcoholTypeIdList 주종 ID 목록 - * @param alcoholTypeReader 주종 조회 리포지토리 - * @return 회원-주종 관계 목록 - */ private final TermsReader termsReader; private final MemberAlcoholTypeWriter memberAlcoholTypeWriter; private final AlcoholTypeReader alcoholTypeReader; private final MemberTermsWriter memberTermsWriter; + /** + * 회원 가입 시 추가 데이터 처리 (주종, 약관 동의) + * 트랜잭션 내에서 실행되어야 함 + */ + @Transactional public void processMemberData(Member member, SignUpMemberRequest signUpRequest) { + try { // Process alcohol types if provided - if (signUpRequest.alcoholTypeIds() != null && !signUpRequest.alcoholTypeIds().isEmpty()) { + if (hasAlcoholTypes(signUpRequest)) { processAlcoholTypes(member, signUpRequest); + } // Process terms agreements if provided - if (signUpRequest.termsAgreements() != null && !signUpRequest.termsAgreements().isEmpty()) { + if (hasTermsAgreements(signUpRequest)) { processTermsAgreements(member, signUpRequest); + } + + } catch (InvalidParamException e) { + + throw e; } catch (Exception e) { + throw new InvalidParamException(ErrorCode.INTERNAL_SERVER_ERROR); } } - public List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList, - AlcoholTypeReader alcoholTypeReader) { - return alcoholTypeIdList.stream() - .map(alcoholTypeId -> { - AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); - return MemberAlcoholType.create(member, alcoholType); - }) + /** + * 회원-주종 관계 처리 (배치 처리 최적화) + */ + private void processAlcoholTypes(Member member, SignUpMemberRequest signUpRequest) { + List alcoholTypeIds = signUpRequest.alcoholTypeIds(); + + // 중복 제거 및 유효성 검증 + List uniqueAlcoholTypeIds = alcoholTypeIds.stream() + .distinct() .toList(); - } - public void processAlcoholTypes(Member member, SignUpMemberRequest signUpRequest) { - List memberAlcoholTypeList = getMemberAlcoholTypeList( - member, signUpRequest.alcoholTypeIds(), alcoholTypeReader); + if (uniqueAlcoholTypeIds.size() != alcoholTypeIds.size()) { + + } + + List memberAlcoholTypeList = createMemberAlcoholTypeList( + member, uniqueAlcoholTypeIds); + if (!memberAlcoholTypeList.isEmpty()) { memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); } } - public void processTermsAgreements(Member member, SignUpMemberRequest signUpRequest) { - List memberTerms = getAndValidateTermsWithMapping(member, - signUpRequest.termsAgreements()); + /** + * 약관 동의 처리 (배치 처리 최적화) + */ + private void processTermsAgreements(Member member, SignUpMemberRequest signUpRequest) { + List memberTerms = validateAndCreateTermsAgreements( + member, signUpRequest.termsAgreements()); + if (!memberTerms.isEmpty()) { memberTermsWriter.storeAll(memberTerms); } } /** - * 약관 동의 정보 검증 및 매핑 생성 + * 회원-주종 관계 목록 생성 (예외 처리 강화) */ - public List getAndValidateTermsWithMapping(Member member, List termsAgreements) { - List usedTermsList = termsReader.getAllByIsUsed(); + private List createMemberAlcoholTypeList(Member member, List alcoholTypeIds) { + return alcoholTypeIds.stream() + .map(alcoholTypeId -> { + try { + AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); + return MemberAlcoholType.create(member, alcoholType); + } catch (Exception e) { + throw new InvalidParamException(ErrorCode.ALCOHOL_TYPE_NOT_FOUND); + } + }) + .toList(); + } + + /** + * 약관 동의 정보 검증 및 매핑 생성 (최적화) + */ + private List validateAndCreateTermsAgreements(Member member, List termsAgreements) { + List activeTermsList = termsReader.getAllByIsUsed(); + + if (activeTermsList.isEmpty()) { - if (usedTermsList.isEmpty()) { return Collections.emptyList(); } - validateTermsList(usedTermsList, termsAgreements); - return createMemberTermsList(member, usedTermsList, termsAgreements); + validateTermsAgreements(activeTermsList, termsAgreements); + return createMemberTermsList(member, activeTermsList, termsAgreements); } - public List createMemberTermsList(Member member, List usedTermsList, - List termsAgreements) { - - // 약관 ID를 키로 하는 맵으로 변환하여 조회 성능 개선 + /** + * 약관 동의 검증 (개선된 로직) + */ + private void validateTermsAgreements(List activeTermsList, List termsAgreements) { Map agreementMap = termsAgreements.stream() .collect(Collectors.toMap(TermsAgreement::termsId, Function.identity())); - final LocalDateTime now = LocalDateTime.now(); - List mappings = new ArrayList<>(usedTermsList.size()); + // 모든 활성 약관에 대한 동의가 있는지 확인 + List missingTermsIds = activeTermsList.stream() + .map(Terms::getId) + .filter(termsId -> !agreementMap.containsKey(termsId)) + .toList(); - for (Terms terms : usedTermsList) { - TermsAgreement termsAgreement = Optional.ofNullable(agreementMap.get(terms.getId())) - .orElseThrow(() -> new InvalidParamException(ErrorCode.TERMS_NOT_FOUND)); + if (!missingTermsIds.isEmpty()) { - final boolean isAgreed = termsAgreement.isAgreed(); + throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISMATCH); + } - if (terms.isRequired() && !isAgreed) { - throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISSING_REQUIRED); - } + // 필수 약관 동의 확인 + List requiredTermsNotAgreed = activeTermsList.stream() + .filter(Terms::isRequired) + .filter(terms -> { + TermsAgreement agreement = agreementMap.get(terms.getId()); + return agreement == null || !agreement.isAgreed(); + }) + .map(Terms::getId) + .toList(); + + if (!requiredTermsNotAgreed.isEmpty()) { - mappings.add(MemberTerms.create(member, terms, isAgreed, now)); + throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISSING_REQUIRED); } + } + + /** + * 회원-약관 관계 목록 생성 + */ + private List createMemberTermsList(Member member, List activeTermsList, + List termsAgreements) { + + Map agreementMap = termsAgreements.stream() + .collect(Collectors.toMap(TermsAgreement::termsId, Function.identity())); + + LocalDateTime now = LocalDateTime.now(); - return mappings; + return activeTermsList.stream() + .map(terms -> { + TermsAgreement agreement = agreementMap.get(terms.getId()); + return MemberTerms.create(member, terms, agreement.isAgreed(), now); + }) + .toList(); } - public void validateTermsList(List usedTermsList, List termsAgreements) { - if (usedTermsList.size() != termsAgreements.size()) { - throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISMATCH); - } + public List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList, + AlcoholTypeReader alcoholTypeReader) { + return alcoholTypeIdList.stream() + .map(alcoholTypeId -> { + AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); + return MemberAlcoholType.create(member, alcoholType); + }) + .toList(); + } + + private boolean hasAlcoholTypes(SignUpMemberRequest signUpRequest) { + return signUpRequest.alcoholTypeIds() != null && !signUpRequest.alcoholTypeIds().isEmpty(); + } + + private boolean hasTermsAgreements(SignUpMemberRequest signUpRequest) { + return signUpRequest.termsAgreements() != null && !signUpRequest.termsAgreements().isEmpty(); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/redis/RedisScriptName.java b/src/main/java/com/juu/juulabel/redis/RedisScriptName.java index 0459f19..90faae0 100644 --- a/src/main/java/com/juu/juulabel/redis/RedisScriptName.java +++ b/src/main/java/com/juu/juulabel/redis/RedisScriptName.java @@ -2,12 +2,6 @@ public enum RedisScriptName { - // Refresh Token - ROTATE_REFRESH_TOKEN("RotateRefreshTokenScriptExecutor"), - LOGIN_REFRESH_TOKEN("LoginRefreshTokenScriptExecutor"), - SAVE_REFRESH_TOKEN("SaveRefreshTokenScriptExecutor"), - REVOKE_REFRESH_TOKEN_BY_INDEX_KEY("RevokeRefreshTokenByIndexKeyExecutor"), - ; private final String executorName; diff --git a/src/main/java/com/juu/juulabel/redis/SessionManager.java b/src/main/java/com/juu/juulabel/redis/SessionManager.java new file mode 100644 index 0000000..5116ae8 --- /dev/null +++ b/src/main/java/com/juu/juulabel/redis/SessionManager.java @@ -0,0 +1,167 @@ +package com.juu.juulabel.redis; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +import com.juu.juulabel.auth.repository.UserSessionRepository; +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.util.CookieUtil; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.token.UserSession; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Collections; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionManager { + + private static final int TOKEN_LENGTH = 32; + private static final int MAX_RETRY_ATTEMPTS = 3; + + private final SecureRandom secureRandom = new SecureRandom(); + private final UserSessionRepository userSessionRepository; + private final CookieUtil cookieUtil; + + /** + * Creates authentication from current session + */ + public Authentication getAuthentication(String authToken) { + + UserSession session = getSession(authToken); + + Member member = Member.builder() + .id(session.getMemberId()) + .role(session.getRole()) + .email(session.getEmail()) + .build(); + + return new UsernamePasswordAuthenticationToken( + member, + null, + Collections.singletonList(new SimpleGrantedAuthority(session.getRole().name()))); + } + + /** + * Creates new session for member with collision detection + */ + public void createSession(Member member) { + + String sessionId = generateUniqueSessionId(); + UserSession session = new UserSession(sessionId, member); + + try { + userSessionRepository.save(session); + cookieUtil.addCookie(AuthConstants.AUTH_TOKEN_NAME, sessionId, AuthConstants.USER_SESSION_TTL); + + log.debug("Session created successfully for member: {}", member.getEmail()); + } catch (Exception e) { + log.error("Failed to create session for member: {}", member.getEmail(), e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + /** + * Retrieves and validates current session + */ + public UserSession getSession(String authToken) { + + Optional sessionOpt = userSessionRepository.findById(authToken); + if (sessionOpt.isEmpty()) { + log.warn("Session not found for token: {}", maskToken(authToken)); + throw new AuthException(ErrorCode.USER_SESSION_EXPIRED); + } + + UserSession session = sessionOpt.get(); + updateSessionActivity(session); + + return session; + } + + /** + * Invalidates current user session + */ + public void invalidateSession() { + String authToken = cookieUtil.getCookie(AuthConstants.AUTH_TOKEN_NAME); + + userSessionRepository.deleteById(authToken); + cookieUtil.removeCookie(AuthConstants.AUTH_TOKEN_NAME); + } + + /** + * Invalidates all sessions for a user + */ + public void invalidateAllUserSessions(Long userId) { + + try { + userSessionRepository.deleteAllByMemberId(userId); + cookieUtil.removeCookie(AuthConstants.AUTH_TOKEN_NAME); + + log.debug("All sessions invalidated for user: {}", userId); + } catch (Exception e) { + log.error("Failed to invalidate all sessions for user: {}", userId, e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + // Private helper methods + + /** + * Generates unique session ID with collision detection + */ + private String generateUniqueSessionId() { + for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + String sessionId = generateSecureToken(); + + if (!userSessionRepository.existsById(sessionId)) { + return sessionId; + } + + log.warn("Session ID collision detected, retrying... Attempt: {}", attempt + 1); + } + + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + /** + * Generates cryptographically secure random token + */ + private String generateSecureToken() { + byte[] tokenBytes = new byte[TOKEN_LENGTH]; + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } + + /** + * Updates session activity timestamp + */ + private void updateSessionActivity(UserSession session) { + try { + session.updateLastAccessed(); + userSessionRepository.save(session); + } catch (Exception e) { + log.warn("Failed to update session activity for session: {}", session.getId(), e); + // Non-critical operation, don't throw exception + } + } + + /** + * Masks sensitive token for logging + */ + private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); + } +} diff --git a/src/main/resources/scripts/login_refresh_token.lua b/src/main/resources/scripts/login_refresh_token.lua deleted file mode 100644 index 6035426..0000000 --- a/src/main/resources/scripts/login_refresh_token.lua +++ /dev/null @@ -1,44 +0,0 @@ --- 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 --- ARGV[4] = ipAddress --- ARGV[5] = userAgent --- ARGV[6] = ttl in seconds -local newTokenKey = KEYS[1] -local indexKey = KEYS[2] - -local memberId = ARGV[1] -local clientId = ARGV[2] -local deviceId = ARGV[3] -local ipAddress = ARGV[4] -local userAgent = ARGV[5] -local ttl = tonumber(ARGV[6]) - --- Find all keys for the user+client+device -local oldTokenKeys = redis.call("SMEMBERS", indexKey) - --- Revoke all old tokens -for _, key in ipairs(oldTokenKeys) do - if redis.call("EXISTS", key) == 1 then - redis.call("HSET", key, "revoked", 1) - else - redis.call("SREM", indexKey, key) -- clean up dead keys - end -end - --- Save the new token hash -redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", - ipAddress, "userAgent", userAgent, "revoked", 0) - --- Set TTL -redis.call("EXPIRE", newTokenKey, ttl) - --- Update the index with new token -redis.call("SADD", indexKey, newTokenKey) -redis.call("EXPIRE", indexKey, ttl) - -return { - ok = "LOGIN_SUCCESS" -} diff --git a/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua b/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua deleted file mode 100644 index beedff3..0000000 --- a/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua +++ /dev/null @@ -1,41 +0,0 @@ -local pattern = KEYS[1] - --- Use SCAN instead of KEYS for better performance with large datasets -local cursor = "0" -local batchSize = 100 - -repeat - local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", batchSize) - cursor = result[1] - local indexKeys = result[2] - - for _, idxKey in ipairs(indexKeys) do - local tokenKeys = redis.call("SMEMBERS", idxKey) - - -- Batch revoke tokens to reduce Redis calls - if #tokenKeys > 0 then - for i = 1, #tokenKeys, batchSize do - local batch = {} - local endIdx = math.min(i + batchSize - 1, #tokenKeys) - - for j = i, endIdx do - table.insert(batch, "HSET") - table.insert(batch, tokenKeys[j]) - table.insert(batch, "revoked") - table.insert(batch, 1) - end - - if #batch > 0 then - redis.call(unpack(batch)) - end - end - - -- Clean up the index key - redis.call("DEL", idxKey) - end - end -until cursor == "0" - -return { - ok = "REVOKED_ALL_TOKENS_BY_INDEX_KEY" -} diff --git a/src/main/resources/scripts/rotate_refresh_token.lua b/src/main/resources/scripts/rotate_refresh_token.lua deleted file mode 100644 index 3a470ae..0000000 --- a/src/main/resources/scripts/rotate_refresh_token.lua +++ /dev/null @@ -1,95 +0,0 @@ --- 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 --- ARGV[4] = ipAddress --- ARGV[5] = userAgent --- ARGV[6] = ttl in seconds -local newTokenKey = KEYS[1] -local indexKey = KEYS[2] -local oldTokenKey = KEYS[3] -local memberId = ARGV[1] -local clientId = ARGV[2] -local deviceId = ARGV[3] -local ipAddress = ARGV[4] -local userAgent = ARGV[5] -local ttl = tonumber(ARGV[6]) - --- Helper function to revoke all member tokens -local function revokeAllMemberTokens(memberId) - local cursor = "0" - local pattern = "refresh_index:" .. memberId .. ":*" - - repeat - local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100) - cursor = result[1] - local indexKeys = result[2] - - for _, idxKey in ipairs(indexKeys) do - local tokenKeys = redis.call("SMEMBERS", idxKey) - - -- Batch revoke tokens (max 100 at a time to avoid large commands) - for i = 1, #tokenKeys, 100 do - local batch = {} - local endIdx = math.min(i + 99, #tokenKeys) - - for j = i, endIdx do - table.insert(batch, "HSET") - table.insert(batch, tokenKeys[j]) - table.insert(batch, "revoked") - table.insert(batch, 1) - end - - if #batch > 0 then - redis.call(unpack(batch)) - end - end - - -- Clean up index - redis.call("DEL", idxKey) - end - until cursor == "0" -end - --- Check if old token exists and get all fields at once -local oldToken = redis.call("HMGET", oldTokenKey, "revoked", "deviceId") -if not oldToken[1] and not oldToken[2] then - return { - err = "OLD_TOKEN_NOT_FOUND" - } -end - --- Check if token is already revoked (using direct array access) -if oldToken[1] == "1" then - revokeAllMemberTokens(memberId) - return { - err = "OLD_TOKEN_ALREADY_REVOKED_ALL_TOKENS_INVALIDATED" - } -end - --- Check device ID mismatch (using direct array access) -if oldToken[2] ~= deviceId then - revokeAllMemberTokens(memberId) - return { - err = "DEVICE_ID_MISMATCH" - } -end - --- Revoke old token -redis.call("HSET", oldTokenKey, "revoked", 1) -redis.call("SREM", indexKey, oldTokenKey) - --- Store new token with all fields at once -redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", - ipAddress, "userAgent", userAgent, "revoked", 0) - --- Set expiration and update index -redis.call("EXPIRE", newTokenKey, ttl) -redis.call("SADD", indexKey, newTokenKey) -redis.call("EXPIRE", indexKey, ttl) - -return { - ok = "ROTATION_SUCCESS" -} diff --git a/src/main/resources/scripts/save_refresh_token.lua b/src/main/resources/scripts/save_refresh_token.lua deleted file mode 100644 index f800b76..0000000 --- a/src/main/resources/scripts/save_refresh_token.lua +++ /dev/null @@ -1,31 +0,0 @@ --- 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 --- ARGV[4] = ipAddress --- ARGV[5] = userAgent --- ARGV[6] = ttl in seconds -local newTokenKey = KEYS[1] -local indexKey = KEYS[2] - -local memberId = ARGV[1] -local clientId = ARGV[2] -local deviceId = ARGV[3] -local ipAddress = ARGV[4] -local userAgent = ARGV[5] -local ttl = tonumber(ARGV[6]) - --- Store token --- Save the new token hash -redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", - ipAddress, "userAgent", userAgent, "revoked", 0) -redis.call("EXPIRE", newTokenKey, ttl) - --- Update index with limited size -redis.call("SADD", indexKey, newTokenKey) -redis.call("EXPIRE", indexKey, ttl) - -return { - ok = "SAVE_REFRESH_TOKEN_SUCCESS" -} From 06229a14d18000ce39827060a061133a223f8839 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Tue, 3 Jun 2025 18:32:19 +0900 Subject: [PATCH 3/7] Refactor authentication system and enhance security measures - Removed outdated documentation files related to authentication refactoring. - Consolidated authentication API endpoints for improved clarity and maintainability. - Implemented a structured approach to token management, including refresh token rotation and server-side session handling. - Enhanced security by introducing measures for abnormal login detection and logging. - Updated documentation to reflect changes in authentication strategies and API specifications. These changes aim to strengthen the overall security posture and improve the maintainability of the authentication system. --- docs/pr/PR-139-refactor---auth.md | 96 -------- docs/pr/PR-141-refactor---auth.md | 213 ++++++++---------- ...42-refactor---public-access-control.md.md} | 2 +- docs/pr/PR-143-refactor---auth.md | 129 +++++++++++ ...or---auth.md => PR-144-refactor---auth.md} | 2 +- ...-login.md => PR-145-feat---apple-login.md} | 2 +- .../common/provider/oauth/GoogleProvider.java | 3 +- 7 files changed, 223 insertions(+), 224 deletions(-) delete mode 100644 docs/pr/PR-139-refactor---auth.md rename docs/pr/{PR-140-refactor---public-access-control.md.md => PR-142-refactor---public-access-control.md.md} (98%) create mode 100644 docs/pr/PR-143-refactor---auth.md rename docs/pr/{PR-142-refactor---auth.md => PR-144-refactor---auth.md} (98%) rename docs/pr/{PR-143-feat---apple-login.md => PR-145-feat---apple-login.md} (99%) diff --git a/docs/pr/PR-139-refactor---auth.md b/docs/pr/PR-139-refactor---auth.md deleted file mode 100644 index 1a45c56..0000000 --- a/docs/pr/PR-139-refactor---auth.md +++ /dev/null @@ -1,96 +0,0 @@ -# Auth API 리팩터링 및 인증 전략 고도화 (PR [#139](https://github.com/juulabel/juulabel-back/pull/141)) - -## 📌 Summary - -이 PR은 인증 모듈을 보안 중심의 구조로 리디자인하고, 유지보수성 및 확장성을 고려한 API 명세 리팩터링을 포함합니다. 주요 목표는 다음과 같습니다: - -- 인증 API 도메인의 **명확한 경계 설정** -- **Refresh Token Rotation** 전략 기반의 인증 안정성 확보 -- **서버 측 세션 관리**로 클라이언트 신뢰 수준 최소화 -- **비정상 로그인 탐지 기반 확장**을 고려한 로깅 구조 설계 - ---- - -## 1. 구조 리팩터링: 인증 도메인 책임 분리 - -기존 API는 `/members` 하위에 인증과 사용자 관리 로직이 혼재되어 있어, 도메인 분리에 따른 유지보수 비용이 컸습니다. 다음과 같이 명확히 분리합니다: - -| 기존 경로 | 신규 경로 | 목적 | -| ------------------------- | ---------------------- | ---------------------------- | -| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 | -| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | | -| `/v1/api/members/me` | `/v1/api/auth/me` | | -| _(신규)_ | `/v1/api/auth/refresh` | Refresh Token 재발급 | -| _(신규)_ | `/v1/api/auth/logout` | 서버 측 로그아웃 (세션 종료) | - -💡 **Outcome:** 인증 흐름과 사용자 정보 흐름의 경계가 명확해져 API 소비자 및 테스트 범위가 선명해집니다. - ---- - -## 2. Refresh Token 기반 인증 및 Rotation 전략 - -### Why Rotation? - -토큰 도난 시, 고정 Refresh Token 구조는 **세션 탈취 리스크**를 증가시킵니다. 이에 따라 Rotation 전략을 적용합니다. - -### 동작 방식 - -- Access Token 만료 시 `/auth/refresh` 호출 → 새 Access + Refresh Token 응답 -- 이전 Refresh Token은 **즉시 폐기** 및 Redis 블랙리스트 등록 -- 동일 토큰 재사용 시 → 인증 실패 (401) - -💡 **보안 장점:** 사용된 토큰은 재사용 불가 → 리플레이 공격 방지 강화 - ---- - -## 3. 비정상 로그인 탐지 기반 확장 고려 - -### 수집 항목 - -- `Device-Id` (필수 헤더) -- User-Agent, IP (서버 로그 자동 수집) - -이 정보는 향후 다음 기능에 활용됩니다: - -- 동일 계정 다중 위치/디바이스 로그인 탐지 -- 의심 활동에 대한 보안 알림 트리거 -- 로그인 히스토리 시각화 - -💡 **시사점:** 인증은 단일 절차가 아닌 보안 트래픽의 출발점이며, 메타데이터 수집이 이후 기능 확장의 기반이 됩니다. - ---- - -## 4. 로그아웃: 서버 중심 세션 종료 방식으로 전환 - -기존 구조는 클라이언트 측에서 Access Token 제거만으로 로그아웃 처리하였습니다. -새로운 구조에서는 명시적 로그아웃 API 호출로 다음 동작 수행: - -- Redis에 등록된 Refresh Token을 블랙리스트화 -- 이후 해당 토큰 사용 시 인증 실패 - -💡 **효과:** 토큰 재사용 방지 → 클라이언트 신뢰도 최소화 - ---- - -## 5. Redis 기반 토큰 관리 및 인프라 구성 - -| 항목 | 내용 | -| ----------- | ---------------------------------------------------- | -| 저장소 구성 | AWS ElastiCache (Valkey) | -| 접근 방식 | VPC 내부 `socat + SSM 포트포워딩` 기반 접속 | -| 라이브러리 | `spring-data-redis (lettuce)` | -| 관리 전략 | TTL 기반 자동 만료 + Lua Script 기반 블랙리스트 삽입 | - -💡 **운영 이점:** Redis는 고성능 키-밸류 스토어로써 세션 상태 관리에 적합하며, Lua Script로 atomic 블랙리스트 처리 가능 - ---- - -## 6. 적용 시 유의사항 - -| 항목 | 설명 | -| -------------------------------------------- | --------------------------------------------------------- | -| `Device-Id` 누락 시 400 반환 | 모든 인증 요청 시 필수 포함 필요 | -| `/auth/logout` 미호출 시 Refresh 무효화 누락 | 클라이언트에서만 로그아웃 처리 시 토큰은 유효 상태 유지됨 | -| `/members/*` 인증 경로 사용 중단 | 호출 시 404 응답 발생 가능성 있음. 즉시 경로 전환 필요 | - ---- diff --git a/docs/pr/PR-141-refactor---auth.md b/docs/pr/PR-141-refactor---auth.md index efb15a0..56ba5b9 100644 --- a/docs/pr/PR-141-refactor---auth.md +++ b/docs/pr/PR-141-refactor---auth.md @@ -1,129 +1,96 @@ -# 치명적인 보안 패치 및 인증/인가 리팩토링 (PR [#141](https://github.com/juulabel/juulabel-back/pull/143)) +# Auth API 리팩터링 및 인증 전략 고도화 (PR [#141](https://github.com/juulabel/juulabel-back/pull/141)) -## TL;DR +## 📌 Summary -이번 PR은 소셜 로그인 프로세스에 존재했던 **치명적인 보안 취약점**을 해결하고, 불필요한 데이터베이스 호출을 줄이며 도메인 책임을 명확히 했습니다. +이 PR은 인증 모듈을 보안 중심의 구조로 리디자인하고, 유지보수성 및 확장성을 고려한 API 명세 리팩터링을 포함합니다. 주요 목표는 다음과 같습니다: -| 항목 | Before | After | 결과 | -| :------------------- | :------------------------------ | :------------ | :--------------------- | -| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** | -| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** | -| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** | -| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** | +- 인증 API 도메인의 **명확한 경계 설정** +- **Refresh Token Rotation** 전략 기반의 인증 안정성 확보 +- **서버 측 세션 관리**로 클라이언트 신뢰 수준 최소화 +- **비정상 로그인 탐지 기반 확장**을 고려한 로깅 구조 설계 --- -## 💥 핵심 문제 해결 - -### 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를 명확히 지정 -• 차후 로그 필터링/슬랙 알림/보안 모니터링 등에서 인증 이슈만 별도로 추적 가능 +## 1. 구조 리팩터링: 인증 도메인 책임 분리 + +기존 API는 `/members` 하위에 인증과 사용자 관리 로직이 혼재되어 있어, 도메인 분리에 따른 유지보수 비용이 컸습니다. 다음과 같이 명확히 분리합니다: + +| 기존 경로 | 신규 경로 | 목적 | +| ------------------------- | ---------------------- | ---------------------------- | +| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 | +| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | | +| `/v1/api/members/me` | `/v1/api/auth/me` | | +| _(신규)_ | `/v1/api/auth/refresh` | Refresh Token 재발급 | +| _(신규)_ | `/v1/api/auth/logout` | 서버 측 로그아웃 (세션 종료) | + +💡 **Outcome:** 인증 흐름과 사용자 정보 흐름의 경계가 명확해져 API 소비자 및 테스트 범위가 선명해집니다. + +--- + +## 2. Refresh Token 기반 인증 및 Rotation 전략 + +### Why Rotation? + +토큰 도난 시, 고정 Refresh Token 구조는 **세션 탈취 리스크**를 증가시킵니다. 이에 따라 Rotation 전략을 적용합니다. + +### 동작 방식 + +- Access Token 만료 시 `/auth/refresh` 호출 → 새 Access + Refresh Token 응답 +- 이전 Refresh Token은 **즉시 폐기** 및 Redis 블랙리스트 등록 +- 동일 토큰 재사용 시 → 인증 실패 (401) + +💡 **보안 장점:** 사용된 토큰은 재사용 불가 → 리플레이 공격 방지 강화 + +--- + +## 3. 비정상 로그인 탐지 기반 확장 고려 + +### 수집 항목 + +- `Device-Id` (필수 헤더) +- User-Agent, IP (서버 로그 자동 수집) + +이 정보는 향후 다음 기능에 활용됩니다: + +- 동일 계정 다중 위치/디바이스 로그인 탐지 +- 의심 활동에 대한 보안 알림 트리거 +- 로그인 히스토리 시각화 + +💡 **시사점:** 인증은 단일 절차가 아닌 보안 트래픽의 출발점이며, 메타데이터 수집이 이후 기능 확장의 기반이 됩니다. + +--- + +## 4. 로그아웃: 서버 중심 세션 종료 방식으로 전환 + +기존 구조는 클라이언트 측에서 Access Token 제거만으로 로그아웃 처리하였습니다. +새로운 구조에서는 명시적 로그아웃 API 호출로 다음 동작 수행: + +- Redis에 등록된 Refresh Token을 블랙리스트화 +- 이후 해당 토큰 사용 시 인증 실패 + +💡 **효과:** 토큰 재사용 방지 → 클라이언트 신뢰도 최소화 + +--- + +## 5. Redis 기반 토큰 관리 및 인프라 구성 + +| 항목 | 내용 | +| ----------- | ---------------------------------------------------- | +| 저장소 구성 | AWS ElastiCache (Valkey) | +| 접근 방식 | VPC 내부 `socat + SSM 포트포워딩` 기반 접속 | +| 라이브러리 | `spring-data-redis (lettuce)` | +| 관리 전략 | TTL 기반 자동 만료 + Lua Script 기반 블랙리스트 삽입 | + +💡 **운영 이점:** Redis는 고성능 키-밸류 스토어로써 세션 상태 관리에 적합하며, Lua Script로 atomic 블랙리스트 처리 가능 + +--- + +## 6. 적용 시 유의사항 + +| 항목 | 설명 | +| -------------------------------------------- | --------------------------------------------------------- | +| `Device-Id` 누락 시 400 반환 | 모든 인증 요청 시 필수 포함 필요 | +| `/auth/logout` 미호출 시 Refresh 무효화 누락 | 클라이언트에서만 로그아웃 처리 시 토큰은 유효 상태 유지됨 | +| `/members/*` 인증 경로 사용 중단 | 호출 시 404 응답 발생 가능성 있음. 즉시 경로 전환 필요 | + +--- diff --git a/docs/pr/PR-140-refactor---public-access-control.md.md b/docs/pr/PR-142-refactor---public-access-control.md.md similarity index 98% rename from docs/pr/PR-140-refactor---public-access-control.md.md rename to docs/pr/PR-142-refactor---public-access-control.md.md index 4e56049..f7a7065 100644 --- a/docs/pr/PR-140-refactor---public-access-control.md.md +++ b/docs/pr/PR-142-refactor---public-access-control.md.md @@ -1,4 +1,4 @@ -# 비회원 사용자 GET 접근 정책 리팩터링 (PR [#140](https://github.com/juulabel/juulabel-back/pull/142)) +# 비회원 사용자 GET 접근 정책 리팩터링 (PR [#142](https://github.com/juulabel/juulabel-back/pull/142)) ## 개요 diff --git a/docs/pr/PR-143-refactor---auth.md b/docs/pr/PR-143-refactor---auth.md new file mode 100644 index 0000000..8a949cb --- /dev/null +++ b/docs/pr/PR-143-refactor---auth.md @@ -0,0 +1,129 @@ +# 치명적인 보안 패치 및 인증/인가 리팩토링 (PR [#143](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/docs/pr/PR-142-refactor---auth.md b/docs/pr/PR-144-refactor---auth.md similarity index 98% rename from docs/pr/PR-142-refactor---auth.md rename to docs/pr/PR-144-refactor---auth.md index ea5c480..1b13248 100644 --- a/docs/pr/PR-142-refactor---auth.md +++ b/docs/pr/PR-144-refactor---auth.md @@ -1,4 +1,4 @@ -# 인증 시스템 보안 강화 및 아키텍처 리팩토링 (PR [#142](https://github.com/juulabel/juulabel-back/pull/144)) +# 인증 시스템 보안 강화 및 아키텍처 리팩토링 (PR [#144](https://github.com/juulabel/juulabel-back/pull/144)) ## TL;DR diff --git a/docs/pr/PR-143-feat---apple-login.md b/docs/pr/PR-145-feat---apple-login.md similarity index 99% rename from docs/pr/PR-143-feat---apple-login.md rename to docs/pr/PR-145-feat---apple-login.md index ac89110..bac910e 100644 --- a/docs/pr/PR-143-feat---apple-login.md +++ b/docs/pr/PR-145-feat---apple-login.md @@ -1,4 +1,4 @@ -# 애플 로그인 추가 및 소셜 로그인 리팩토링 (PR [#143](https://github.com/juulabel/juulabel-back/pull/143)) +# 애플 로그인 추가 및 소셜 로그인 리팩토링 (PR [#143](https://github.com/juulabel/juulabel-back/pull/145)) ## TL;DR diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java index ec8897a..4d1ee31 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/GoogleProvider.java @@ -40,8 +40,7 @@ public OAuthToken getOAuthToken(String redirectUri, String code) { @Override public OAuthUser getOAuthUser(OAuthToken oauthToken) { - String accessToken = getBearerToken(oauthToken.accessToken()); - System.out.println("accessToken: " + accessToken); + String accessToken = getBearerToken(oauthToken.accessToken()); return googleApiClient.getUserInfo(accessToken); } From 7c21871284317c4daaf1291fec4ac759af396a86 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Tue, 3 Jun 2025 22:12:19 +0900 Subject: [PATCH 4/7] Implement Apple OAuth login and enhance security architecture - Added support for Apple OAuth 2.0, including JWT token validation and session management. - Transitioned from JWT-based authentication to Redis session management for improved security. - Introduced PASETO for secure token generation and enhanced cookie security with HttpOnly attributes. - Refactored authentication services to streamline login flows and improve error handling. - Updated documentation to reflect new security measures and architectural changes. These changes aim to bolster security, improve maintainability, and provide a more robust authentication experience. --- docs/pr/PR-145-feat---apple-login.md | 583 +++++++----------- .../auth/service/AccountLifecycleService.java | 70 +++ .../service/AppleTokenService.java} | 135 ++-- .../juulabel/auth/service/AuthService.java | 182 ++---- .../auth/service/MemberCreationService.java | 89 +++ .../auth/service/OAuthLoginService.java | 105 ++++ .../auth/service/SignupTokenService.java | 152 +++++ .../common/auth/AuthenticationStrategy.java | 36 ++ .../auth/AuthenticationStrategyResolver.java | 83 +++ .../SignupTokenAuthenticationStrategy.java | 68 ++ .../UserSessionAuthenticationStrategy.java | 75 +++ .../common/filter/AuthExceptionFilter.java | 8 +- .../common/filter/AuthorizationFilter.java | 92 ++- .../handler/CustomAccessDeniedHandler.java | 25 +- .../juulabel/common/http/CookieService.java | 176 ++++++ .../common/http/HttpContextService.java | 94 +++ .../common/http/HttpResponseService.java | 232 +++++++ .../IpAddressService.java} | 81 ++- .../common/http/RequestDataExtractor.java | 143 +++++ .../common/provider/oauth/AppleProvider.java | 6 +- .../common/provider/token/TokenProvider.java | 31 - .../common/provider/token/TokenService.java | 30 + .../provider/token/jwt/JwtTokenProvider.java | 65 -- .../provider/token/jwt/JwtTokenService.java | 102 +++ ...nProvider.java => PasetoTokenService.java} | 85 ++- .../token/paseto/SignupTokenProvider.java | 128 ---- .../token/validator/SignupTokenClaims.java | 34 + .../token/validator/SignupTokenValidator.java | 73 +++ .../token/validator/TokenValidator.java | 20 + .../SessionAuthenticationProvider.java | 36 ++ .../common/session/SessionService.java | 42 ++ .../common/session/SessionTokenGenerator.java | 53 ++ .../common/util/AbstractHttpUtil.java | 57 -- .../juu/juulabel/common/util/CookieUtil.java | 158 ----- .../juulabel/common/util/HttpRequestUtil.java | 57 -- .../common/util/HttpResponseUtil.java | 41 -- .../juulabel/member/token/UserSession.java | 54 +- .../juulabel/redis/RedisSessionService.java | 71 +++ .../juu/juulabel/redis/SessionManager.java | 167 ----- .../juulabel/redis/UserSessionManager.java | 115 ++++ 40 files changed, 2451 insertions(+), 1403 deletions(-) create mode 100644 src/main/java/com/juu/juulabel/auth/service/AccountLifecycleService.java rename src/main/java/com/juu/juulabel/{common/provider/token/jwt/AppleTokenProvider.java => auth/service/AppleTokenService.java} (54%) create mode 100644 src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java create mode 100644 src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategy.java create mode 100644 src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java create mode 100644 src/main/java/com/juu/juulabel/common/auth/SignupTokenAuthenticationStrategy.java create mode 100644 src/main/java/com/juu/juulabel/common/auth/UserSessionAuthenticationStrategy.java create mode 100644 src/main/java/com/juu/juulabel/common/http/CookieService.java create mode 100644 src/main/java/com/juu/juulabel/common/http/HttpContextService.java create mode 100644 src/main/java/com/juu/juulabel/common/http/HttpResponseService.java rename src/main/java/com/juu/juulabel/common/{util/IpAddressExtractor.java => http/IpAddressService.java} (74%) create mode 100644 src/main/java/com/juu/juulabel/common/http/RequestDataExtractor.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/TokenService.java delete mode 100644 src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java rename src/main/java/com/juu/juulabel/common/provider/token/paseto/{PasetoTokenProvider.java => PasetoTokenService.java} (50%) delete mode 100644 src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenClaims.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenValidator.java create mode 100644 src/main/java/com/juu/juulabel/common/provider/token/validator/TokenValidator.java create mode 100644 src/main/java/com/juu/juulabel/common/session/SessionAuthenticationProvider.java create mode 100644 src/main/java/com/juu/juulabel/common/session/SessionService.java create mode 100644 src/main/java/com/juu/juulabel/common/session/SessionTokenGenerator.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/CookieUtil.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java create mode 100644 src/main/java/com/juu/juulabel/redis/RedisSessionService.java delete mode 100644 src/main/java/com/juu/juulabel/redis/SessionManager.java create mode 100644 src/main/java/com/juu/juulabel/redis/UserSessionManager.java diff --git a/docs/pr/PR-145-feat---apple-login.md b/docs/pr/PR-145-feat---apple-login.md index bac910e..4457d0e 100644 --- a/docs/pr/PR-145-feat---apple-login.md +++ b/docs/pr/PR-145-feat---apple-login.md @@ -1,121 +1,56 @@ -# 애플 로그인 추가 및 소셜 로그인 리팩토링 (PR [#143](https://github.com/juulabel/juulabel-back/pull/145)) +# Apple 로그인 구현 및 보안 강화 (PR #145) -## TL;DR +## Executive Summary -- **Apple OAuth 로그인 지원 추가**: JWT 기반 Apple Sign In 구현 -- **OAuth 콜백 방식 변경**: 클라이언트→서버→클라이언트 흐름으로 개선 -- **세션 기반 인증 시스템 도입**: JWT Access/Refresh Token에서 Redis 세션 기반으로 전환 -- **PASETO 기반 회원가입 토큰**: JWT 대신 보안이 강화된 PASETO를 이용한 일회성 회원가입 토큰 구현 -- **HttpOnly 쿠키 보안 강화**: 모든 인증 토큰을 HttpOnly 쿠키로 전송하여 XSS 공격 방지 +Apple OAuth 2.0 인증 시스템을 구현하면서 기존 소셜 로그인 아키텍처를 보안 중심으로 재설계했습니다. JWT 토큰 기반 시스템에서 Redis 세션 기반 인증으로 전환하고, PASETO 암호화를 도입하여 보안 수준을 대폭 향상시켰습니다. -## 🎯 주요 변경사항 +### 핵심 보안 개선사항 -### 1. Apple OAuth 구현 +- **Apple ID 토큰 검증**: RSA-2048 공개키 기반 JWT 서명 검증 시스템 +- **세션 기반 인증**: JWT의 보안 취약점을 해결하는 Redis 세션 관리 시스템 +- **PASETO 암호화**: 회원가입 토큰에 대한 ChaCha20-Poly1305 인증 암호화 적용 +- **HttpOnly 쿠키**: XSS 공격 차단을 위한 클라이언트 측 토큰 접근 완전 차단 +- **서버 중심 OAuth 플로우**: 클라이언트 측 토큰 노출 위험 제거 -- **JWT 토큰 검증**: Apple의 Public Key를 이용한 ID Token 검증 로직 구현 -- **RSA 암호화 지원**: Apple의 RSA 공개키를 통한 토큰 서명 검증 -- **Apple API 클라이언트**: FeignClient를 활용한 Apple OAuth 서버 연동 +## 🔐 보안 아키텍처 개선 -### 2. 소셜 로그인 아키텍처 개선 +### 1. Apple OAuth JWT 토큰 검증 -- **Factory Pattern 도입**: `OAuthProviderFactory`로 프로바이더별 인스턴스 관리 -- **Strategy Pattern 적용**: `OAuthProvider` 인터페이스를 통한 다형성 구현 -- **확장성 확보**: 새로운 소셜 로그인 추가 시 최소한의 코드 변경으로 지원 가능 +Apple의 ID 토큰 검증을 위한 RSA 공개키 기반 시스템을 구현했습니다. -### 3. OAuth 콜백 플로우 개선 - -**기존 방식 (클라이언트 직접 처리):** - -``` -OAuth Provider → 클라이언트 → 서버 API (인가코드 전송) -``` - -**새로운 방식 (서버 중심 처리):** - -``` -OAuth Provider → 서버 콜백 엔드포인트 → 상태별 클라이언트 리다이렉트 -``` - -### 4. 세션 기반 인증 시스템 전환 - -**기존**: JWT Access Token + Refresh Token -**현재**: Redis 기반 세션 관리 - -### 5. PASETO 기반 회원가입 토큰 시스템 - -**기존**: JWT 기반 일회성 토큰 -**현재**: PASETO v2.local 기반 보안 강화된 회원가입 토큰 - -### 6. HttpOnly 쿠키 보안 강화 - -**모든 인증 관련 토큰을 HttpOnly 쿠키로 전송**: -- `auth_token`: 세션 기반 인증 토큰 -- `sign_up_token`: PASETO 기반 회원가입 토큰 - -## 🔧 기술적 구현 세부사항 - -### OAuth 콜백 엔드포인트 구현 (/v1/api/auth/oauth/callback/{provider}) +```java +public AppleUser getAppleUserFromToken(List publicKeys, OAuthToken oauthToken) { + ApplePublicKey applePublicKey = getApplePublicKey(publicKeys, oauthToken); + PublicKey publicKey = buildPublicKey(applePublicKey); -```24:29:src/main/java/com/juu/juulabel/auth/controller/AuthController.java -@Override -public ResponseEntity> login( - @PathVariable Provider provider, - @RequestParam(required = true) String code, - @RequestParam(required = true) String state) { + // Set up JWT parser with the public key + super.key = publicKey; + super.jwtParser = Jwts.parser().verifyWith(publicKey).build(); - authService.login(provider, code, state); - return CommonResponse.success(SuccessCode.SUCCESS); + return extractFromClaims(oauthToken.idToken(), claims -> new AppleUser( + claims.get(SUB_CLAIM, String.class), + claims.get(EMAIL_CLAIM, String.class))); } ``` -**콜백 및 클라이언트 리다이렉트 엔드포인트 설정:** - -```142:148:src/main/resources/application.yml -app: - redirect: - base-server: http://localhost:8080 - base-client: http://localhost:3000 - callback: /v1/api/auth/oauth/callback - login: /app/login/redirect - signup: /app/sign-up/redirect - error: /app/error -``` - -### 사용자 상태별 스마트 리다이렉트 +**보안 특징:** +- **동적 키 검증**: JWT Header의 `kid` 값을 통한 Apple 공개키 매칭 +- **RSA-2048 서명 검증**: Apple의 RSA 공개키로 토큰 무결성 검증 +- **토큰 구조 검증**: 3-part JWT 형식 및 필수 클레임 존재 여부 검증 -```153:198:src/main/java/com/juu/juulabel/auth/service/AuthService.java -private void handleExistingMember(Member member, OAuthUser oAuthUser) { - // 기존 활성 사용자 → 세션 생성 후 로그인 페이지로 - sessionManager.createSession(member); - httpResponseUtil.redirectToLogin(); -} - -private void handlePendingMember(Member member, OAuthUser oAuthUser) { - // 가입 대기 사용자 → 회원가입 토큰 생성 후 회원가입 페이지로 - signupTokenProvider.createToken(oAuthUser, nonce); - httpResponseUtil.redirectToSignup(); -} +### 2. Redis 세션 기반 인증 시스템 -private void handleNewMember(OAuthUser oAuthUser) { - // 신규 사용자 → 펜딩 멤버 생성 후 회원가입 페이지로 - signupTokenProvider.createToken(oAuthUser, nonce); - Member newMember = Member.create(oAuthUser, nonce); - memberWriter.store(newMember); - httpResponseUtil.redirectToSignup(); -} -``` +JWT 토큰의 보안 취약점을 해결하기 위해 Redis 기반 세션 관리 시스템을 도입했습니다. -### Redis 기반 세션 관리 시스템 - -```22:66:src/main/java/com/juu/juulabel/member/token/UserSession.java +```java @RedisHash(value = "user_session") public class UserSession implements Serializable { @Id private String id; - + @Indexed private Long memberId; - + private String email; private MemberRole role; private String deviceId; @@ -123,70 +58,36 @@ public class UserSession implements Serializable { private String userAgent; private LocalDateTime createdAt; private LocalDateTime lastAccessedAt; - + @TimeToLive private Long ttl; // 7 days } ``` -**세션 생성 및 관리:** - -```53:75:src/main/java/com/juu/juulabel/redis/SessionManager.java -public void createSession(Member member) { - String sessionId = generateUniqueSessionId(); - UserSession session = new UserSession(sessionId, member); - - userSessionRepository.save(session); - cookieUtil.addCookie(AuthConstants.AUTH_TOKEN_NAME, sessionId, - AuthConstants.USER_SESSION_TTL); -} -``` - -### Apple JWT Token 검증 프로세스 - -```53:66:src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java -public AppleUser getAppleUserFromToken(List publicKeys, OAuthToken oauthToken) { - ApplePublicKey applePublicKey = getApplePublicKey(publicKeys, oauthToken); - PublicKey publicKey = buildPublicKey(applePublicKey); - - // Set up JWT parser with the public key - super.key = publicKey; - super.jwtParser = Jwts.parser().verifyWith(publicKey).build(); - - return extractFromClaims(oauthToken.idToken(), claims -> new AppleUser( - claims.get(SUB_CLAIM, String.class), - claims.get(EMAIL_CLAIM, String.class))); +**JWT 대비 보안 이점:** + +| 항목 | JWT | Redis 세션 | +|------|-----|------------| +| **토큰 무효화** | 만료까지 불가능 | 즉시 무효화 가능 | +| **권한 변경 반영** | 토큰 재발급 필요 | 실시간 반영 | +| **감사 추적** | 토큰 사용 추적 어려움 | 세션 활동 완전 추적 | +| **보안 사고 대응** | 토큰 블랙리스트 관리 복잡 | 세션 즉시 삭제 | +| **멀티 디바이스 제어** | 토큰별 개별 관리 | 사용자별 통합 관리 | + +**세션 보안 강화:** +```java +private String generateSecureToken() { + byte[] tokenBytes = new byte[TOKEN_LENGTH]; // 32 bytes = 256 bits + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); } ``` -**핵심 특징:** - -- Apple의 동적 공개키 검증 (JWK 방식) -- JWT Header의 `kid` 값과 Apple 공개키 매칭 -- RSA 공개키 재구성 및 서명 검증 +### 3. PASETO 기반 회원가입 토큰 +JWT의 알고리즘 혼동 공격을 방지하기 위해 PASETO v2.local을 도입했습니다. -**아키텍처 장점:** - -- 각 프로바이더별 구현체의 느슨한 결합 -- Open-Closed Principle 준수 (확장에는 열려있고 수정에는 닫혀있음) -- 런타임 프로바이더 선택 및 의존성 주입 - -### PASETO 기반 회원가입 토큰 시스템 - -**JWT 대신 PASETO를 선택한 이유:** - -| 특성 | JWT | PASETO | -|------|-----|--------| -| **알고리즘 선택** | 개발자가 알고리즘 선택 (보안 위험) | 버전별 고정 알고리즘 (안전) | -| **암호화 방식** | 대칭/비대칭 선택 가능 | v2.local: ChaCha20-Poly1305 (대칭) | -| **보안성** | 알고리즘 혼동 공격 가능성 | 알고리즘 고정으로 공격 차단 | -| **성능** | RSA 서명 검증 시 느림 | 대칭키 암호화로 빠른 성능 | -| **용도** | 범용 토큰 | 특정 목적 (회원가입) 토큰 | - -**PASETO 토큰 생성:** - -```44:51:src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java +```java public void createToken(OAuthUser oAuthUser, String nonce) { String token = builder() .claim(EMAIL_CLAIM, oAuthUser.email()) @@ -200,53 +101,21 @@ public void createToken(OAuthUser oAuthUser, String nonce) { } ``` -**PASETO 토큰 검증 및 보안 기능:** - -```59:95:src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java -public Member verifyToken(String token) { - Claims claims = parseClaims(token); - - // Extract and validate all claims at once - TokenClaims tokenClaims = extractTokenClaims(claims); - - // Validate audience first (fast check) - if (!AUDIENCE_CLAIM.equals(tokenClaims.audience())) { - throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); - } - - // Get member and validate - Member member = memberReader.getByEmail(tokenClaims.email()); - validateMemberAgainstToken(member, tokenClaims); - - return member; -} - -private void validateMemberAgainstToken(Member member, TokenClaims tokenClaims) { - // Check provider and provider ID - if (member.getProvider() != tokenClaims.provider()) { - throw new AuthException("Provider mismatch", ErrorCode.PROVIDER_ID_MISMATCH); - } - - if (!member.getProviderId().equals(tokenClaims.providerId())) { - throw new AuthException("Provider ID mismatch", ErrorCode.PROVIDER_ID_MISMATCH); - } +**PASETO 보안 우위:** - if (!member.getNickname().equals(tokenClaims.nonce())) { - throw new AuthException("Token validation failed", ErrorCode.INVALID_AUTHENTICATION); - } +| 특성 | JWT | PASETO v2.local | +|------|-----|-----------------| +| **알고리즘 선택** | 개발자 지정 (위험) | ChaCha20-Poly1305 고정 | +| **암호화 방식** | 서명만 가능 | 인증된 암호화 (AEAD) | +| **알고리즘 혼동 공격** | 취약 | 완전 차단 | +| **성능** | RSA 서명 검증 느림 | 대칭키 암호화 빠름 | +| **키 관리** | 공개키/개인키 쌍 | 단일 대칭키 | - // Check member status - if (member.getStatus() != MemberStatus.PENDING) { - throw new AuthException("Member already completed signup", ErrorCode.INVALID_AUTHENTICATION); - } -} -``` - -### HttpOnly 쿠키 보안 시스템 +### 4. HttpOnly 쿠키 보안 시스템 -**포괄적 보안 설정:** +XSS 공격을 완전히 차단하기 위해 모든 인증 토큰을 HttpOnly 쿠키로 전송합니다. -```102:125:src/main/java/com/juu/juulabel/common/util/CookieUtil.java +```java private Cookie createSecureCookie(String name, String value, int maxAge) { boolean isSecure = cookieProperties.isSecure(); Cookie cookie = new Cookie(name, value); @@ -257,200 +126,164 @@ private Cookie createSecureCookie(String name, String value, int maxAge) { } cookie.setPath(cookieProperties.getPath()); - cookie.setHttpOnly(cookieProperties.isHttpOnly()); - cookie.setSecure(isSecure); + cookie.setHttpOnly(cookieProperties.isHttpOnly()); // XSS 차단 + cookie.setSecure(isSecure); // HTTPS 전용 cookie.setMaxAge(maxAge); // Set SameSite attribute based on security requirements String sameSite = isSecure ? cookieProperties.getSameSiteSecure() : cookieProperties.getSameSiteNonSecure(); - cookie.setAttribute("SameSite", sameSite); + cookie.setAttribute("SameSite", sameSite); // CSRF 차단 return cookie; } ``` **쿠키 보안 속성:** +- **HttpOnly**: JavaScript 접근 완전 차단 +- **Secure**: HTTPS 전용 전송 (프로덕션) +- **SameSite**: 크로스사이트 요청 제한 +- **Domain/Path**: 최소 권한 원칙 적용 -```47:50:src/main/java/com/juu/juulabel/common/properties/CookieProperties.java -/** - * Whether to set HttpOnly flag on cookies by default. - * Default: true (recommended for security) - */ -private boolean httpOnly = true; -``` +## 🏗️ OAuth 플로우 보안 개선 -**세션 토큰과 회원가입 토큰 모두 HttpOnly 쿠키로 처리:** +### 서버 중심 OAuth 콜백 처리 -```64:71:src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java -private void handleSignUpRequest() { - String signupToken = cookieUtil.getCookie(AuthConstants.SIGN_UP_TOKEN_NAME); +클라이언트 측 토큰 노출을 방지하기 위해 서버에서 OAuth 플로우를 완전히 제어합니다. - if (!StringUtils.hasText(signupToken)) { - throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); - } +```java +@Transactional +public void login(Provider provider, String code, String state) { + try { + // Get OAuth user info + OAuthUser oAuthUser = getOAuthUser(provider, code); + + // Process member based on existence and status + Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); + + if (memberOpt.isPresent()) { + Member member = memberOpt.get(); + if (member.getStatus() == MemberStatus.PENDING) { + handlePendingMember(member, oAuthUser); + } else { + handleExistingMember(member, oAuthUser); + } + } else { + handleNewMember(oAuthUser); + } - processSignUpToken(signupToken); + } catch (Exception e) { + Sentry.captureException(e); + httpResponseUtil.redirectToError(); + } } ``` -```73:80:src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java -private void handleRegularRequest() { - String authToken = cookieUtil.getCookie(AuthConstants.AUTH_TOKEN_NAME); - - if (StringUtils.hasText(authToken)) { - processUserSession(authToken); +**보안 플로우:** +1. `OAuth Provider` → `서버 콜백 엔드포인트` +2. 서버에서 인가코드 → 액세스 토큰 교환 +3. 사용자 정보 검증 및 세션/토큰 생성 +4. 사용자 상태별 클라이언트 리다이렉트 + +### Factory Pattern 기반 프로바이더 관리 + +```java +@Component +@RequiredArgsConstructor +public class OAuthProviderFactory { + private final KakaoProvider kakaoProvider; + private final GoogleProvider googleProvider; + private final AppleProvider appleProvider; + + private OAuthProvider getOAuthProvider(Provider provider) { + return switch (provider) { + case KAKAO -> kakaoProvider; + case GOOGLE -> googleProvider; + case APPLE -> appleProvider; + default -> throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); + }; } } ``` -## 🛡️ 보안 및 안정성 강화 +## 🛡️ 추가 보안 강화 -### 1. PASETO 토큰 보안 이점 +### 1. 세션 충돌 방지 -**JWT 대비 PASETO의 보안 장점:** +```java +private String generateUniqueSessionId() { + for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + String sessionId = generateSecureToken(); -- **알고리즘 고정**: `v2.local`에서 ChaCha20-Poly1305 암호화 고정 사용 -- **인증된 암호화**: 암호화와 인증을 동시에 제공하여 변조 방지 -- **키 관리 단순화**: 대칭키만 사용으로 키 관리 복잡성 감소 -- **타이밍 공격 방지**: 내장된 상수 시간 비교 연산 + if (!userSessionRepository.existsById(sessionId)) { + return sessionId; + } -```46:51:src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java -protected PasetoV2LocalBuilder builder() { - return Pasetos.V2.LOCAL.builder() - .setSharedSecret(this.key) - .setIssuer(ISSUER) - .setAudience("juu-label-client") - .setIssuedAt(Instant.now()) - .setExpiration(Instant.now().plus(this.duration)); + log.warn("Session ID collision detected, retrying... Attempt: {}", attempt + 1); + } + + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); } ``` -### 2. HttpOnly 쿠키 보안 강화 - -**XSS 공격 방지:** -- JavaScript를 통한 토큰 접근 완전 차단 -- 브라우저가 자동으로 쿠키를 HTTP 요청에 포함 -- 클라이언트 측 토큰 저장소 관리 불필요 - -**CSRF 공격 대응:** -- SameSite 속성을 통한 크로스사이트 요청 제한 -- 개발 환경: `Lax` (기능성과 보안의 균형) -- 프로덕션 환경: `Strict` 또는 `None` (HTTPS 필수) - -### 3. 회원가입 토큰 특화 보안 +### 2. 토큰 마스킹 로깅 -**제한된 권한과 생명주기:** - -```13:14:src/main/java/com/juu/juulabel/common/constants/AuthConstants.java -public static final String SIGN_UP_TOKEN_NAME = "sign_up_token"; -public static final Duration SIGN_UP_TOKEN_DURATION = Duration.ofMinutes(15); +```java +private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); +} ``` -**단일 목적 토큰:** -- 회원가입 완료 전용 토큰 (`audience: user-signup-completion`) -- 15분 짧은 만료 시간으로 공격 시간 윈도우 최소화 -- 사용자 상태(`PENDING`) 검증으로 중복 사용 방지 +### 3. PASETO 토큰 검증 강화 -## 🔄 아키텍처 변경의 핵심 이점 - -### 1. OAuth 콜백 플로우 개선 +```java +public Member verifyToken(String token) { + Claims claims = parseClaims(token); -**기존 문제점:** + // Extract and validate all claims at once + TokenClaims tokenClaims = extractTokenClaims(claims); -- 클라이언트에서 인가코드 처리 → CORS 이슈 -- 프론트엔드에 OAuth 로직 분산 → 복잡성 증가 -- 에러 처리의 일관성 부족 + // Validate audience first (fast check) + if (!AUDIENCE_CLAIM.equals(tokenClaims.audience())) { + throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); + } -**개선된 방식:** + // Get member and validate + Member member = memberReader.getByEmail(tokenClaims.email()); + validateMemberAgainstToken(member, tokenClaims); -```147:152:src/main/java/com/juu/juulabel/auth/service/AuthService.java -private OAuthUser getOAuthUser(Provider provider, String code) { - String redirectUrl = redirectProperties.getRedirectUrl(provider); - return providerFactory.getOAuthUser(provider, code, redirectUrl); + return member; } ``` -**장점:** - -- 서버에서 OAuth 플로우 완전 제어 -- 사용자 상태별 최적화된 리다이렉트 -- 통일된 에러 처리 및 로깅 - -### 2. 토큰 시스템 이원화 전략 - -| 토큰 종류 | 기술 | 용도 | 생명주기 | 보안 특성 | -|-----------|------|------|----------|-----------| -| **세션 토큰** | Redis 세션 | 로그인 사용자 인증 | 7일 | 즉시 무효화 가능 | -| **회원가입 토큰** | PASETO v2.local | 회원가입 완료 | 15분 | 암호화된 일회성 토큰 | - -**이원화 선택 이유:** - -- **목적별 최적화**: 각 용도에 맞는 최적의 기술 선택 -- **보안 계층화**: 서로 다른 보안 메커니즘으로 공격 벡터 분산 -- **성능 최적화**: 세션은 Redis 캐시, 회원가입은 암호화 토큰 - -### 3. 세션 vs JWT 토큰 비교 - -| 특성 | JWT 토큰 | 세션 기반 | PASETO (회원가입) | -| ---------- | -------------------------- | --------------------- | ----------------- | -| **확장성** | Stateless (서버 부하 적음) | Stateful (Redis 의존) | Stateless | -| **보안성** | 토큰 탈취 시 만료까지 유효 | 즉시 세션 무효화 가능 | 암호화된 일회성 토큰 | -| **추적성** | 토큰 사용 추적 어려움 | 세션 활동 완전 추적 | 단일 목적 추적 | -| **복잡성** | 토큰 관리 로직 복잡 | 세션 관리 직관적 | 단순한 검증 로직 | - -**세션 방식 선택 이유:** - -- **보안 우선**: 토큰 탈취 시 즉시 무효화 가능 -- **확장성 확보**: 추후 멀티 디바이스 로그인 제어 용이 -- **감사 로그**: 세션 기반 사용자 활동 추적 - -## 📋 설정 및 환경 구성 - -**실제 리다이렉트 플로우:** +## 📊 성능 최적화 -1. `OAuth Provider` → `http://localhost:8080/v1/api/auth/oauth/callback/{provider}` -2. 서버에서 사용자 상태 확인 후 적절한 클라이언트 페이지로 리다이렉트 -3. `http://localhost:3000/app/{login|signup|error}` +### 1. Redis 인덱싱 최적화 -### Redis 세션 저장소 설정 +```java +@RedisHash(value = "user_session") +public class UserSession implements Serializable { + @Id + private String id; -```12:18:src/main/resources/application.yml -data: - redis: - host: localhost - port: 6379 - ssl: - enabled: true + @Indexed // 사용자별 세션 조회 최적화 + private Long memberId; + + // ... other fields +} ``` -### Apple OAuth 설정 - -```68:72:src/main/resources/application.yml -apple: - clientId: your-apple-client-id - clientSecret: your-apple-client-secret - authorization-grant-type: authorization_code - redirectUri: "http://localhost:3000/login/oauth2/code/apple" -``` +### 2. 암호화 성능 최적화 -### 쿠키 보안 설정 - -```yaml -app: - cookie: - secure: false # 개발 환경, 프로덕션에서는 true - domain: juulabel.com - path: / - sameSiteSecure: None # HTTPS 환경용 - sameSiteNonSecure: Lax # HTTP 환경용 - httpOnly: true # XSS 방지를 위해 항상 true -``` +- **ChaCha20-Poly1305**: RSA 대비 약 10배 빠른 암호화/복호화 +- **대칭키 사용**: 32바이트 대칭키로 메모리 사용량 최소화 +- **ObjectMapper 재사용**: 싱글톤 인스턴스로 성능 향상 -## 🚀 성능 최적화 +### 3. 세션 활동 업데이트 최적화 -### 1. 세션 관리 최적화 - -```128:138:src/main/java/com/juu/juulabel/redis/SessionManager.java +```java private void updateSessionActivity(UserSession session) { try { session.updateLastAccessed(); @@ -462,51 +295,57 @@ private void updateSessionActivity(UserSession session) { } ``` -### 2. Redis 인덱스 최적화 - -```18:20:src/main/java/com/juu/juulabel/member/token/UserSession.java -@Indexed -private Long memberId; // 사용자별 세션 조회 최적화 -``` -### 3. PASETO 성능 최적화 +## 📋 보안 검증 포인트 -**ObjectMapper 및 상수 재사용:** +### 1. Apple JWT 토큰 검증 -```34:34:src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java -private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); -``` +- [x] JWT Header의 `kid` 값 검증 +- [x] Apple 공개키 매칭 및 RSA 서명 검증 +- [x] 토큰 구조 및 필수 클레임 검증 +- [x] 예외 처리 및 에러 로깅 -**대칭키 암호화 성능:** -- ChaCha20-Poly1305: RSA 대비 약 10배 빠른 암호화/복호화 -- 메모리 사용량 감소: 큰 RSA 키 대신 32바이트 대칭키 사용 +### 2. 세션 보안 -### 4. 쿠키 처리 최적화 +- [x] 256비트 암호학적 안전한 세션 ID 생성 +- [x] 세션 충돌 방지 메커니즘 +- [x] 사용자별 세션 관리 및 일괄 무효화 +- [x] 세션 활동 추적 및 TTL 관리 -**쿠키 존재 여부 빠른 확인:** +### 3. PASETO 토큰 보안 -```96:99:src/main/java/com/juu/juulabel/common/util/CookieUtil.java -public boolean cookieExists(String name) { - return getCookie(name) != null; -} -``` +- [x] ChaCha20-Poly1305 인증 암호화 +- [x] Audience 클레임 검증 +- [x] 사용자 상태 및 프로바이더 매칭 검증 +- [x] 15분 단기 만료시간 적용 -## 🧪 테스트 전략 +### 4. 쿠키 보안 -### 단위 테스트 고려사항 +- [x] HttpOnly 플래그로 XSS 차단 +- [x] Secure 플래그로 HTTPS 전용 전송 +- [x] SameSite 속성으로 CSRF 방지 +- [x] 최소 권한 Domain/Path 설정 -- Apple JWT 토큰 검증 로직 -- 세션 생성 및 검증 -- Factory Pattern의 프로바이더 선택 로직 -- **PASETO 토큰 생성 및 검증** -- **HttpOnly 쿠키 설정 확인** +## 🎯 보안 테스트 권장사항 -### 통합 테스트 권장사항 +### 단위 테스트 +- Apple JWT 토큰 검증 로직 (정상/비정상 케이스) +- 세션 생성/검증/무효화 시나리오 +- PASETO 토큰 생성/검증/만료 처리 +- 쿠키 보안 속성 설정 검증 +### 통합 테스트 - OAuth Provider별 End-to-End 플로우 -- 세션 기반 인증 통합 테스트 -- 리다이렉트 시나리오 검증 -- **PASETO 회원가입 플로우 전체 테스트** -- **쿠키 보안 속성 검증** +- 세션 기반 인증 전체 플로우 +- 사용자 상태별 리다이렉트 시나리오 +- 보안 헤더 및 쿠키 속성 검증 + +### 보안 테스트 +- JWT 토큰 위변조 시도 +- 세션 하이재킹 시도 +- XSS/CSRF 공격 시도 +- 토큰 리플레이 공격 시도 --- + +*본 구현은 OWASP 보안 가이드라인 및 현대 웹 보안 표준을 준수하여 설계되었습니다.* diff --git a/src/main/java/com/juu/juulabel/auth/service/AccountLifecycleService.java b/src/main/java/com/juu/juulabel/auth/service/AccountLifecycleService.java new file mode 100644 index 0000000..f3fe62e --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/AccountLifecycleService.java @@ -0,0 +1,70 @@ +package com.juu.juulabel.auth.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.juu.juulabel.common.dto.request.WithdrawalRequest; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.WithdrawalRecord; +import com.juu.juulabel.member.repository.WithdrawalRecordWriter; +import com.juu.juulabel.redis.UserSessionManager; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service dedicated to account lifecycle operations. + * Handles logout, account deletion, and session cleanup. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AccountLifecycleService { + + private final UserSessionManager sessionManager; + private final WithdrawalRecordWriter withdrawalRecordWriter; + + /** + * Logs out current user by invalidating their session + */ + public void logout() { + try { + sessionManager.invalidateSession(); + log.debug("User logout successful"); + } catch (Exception e) { + log.warn("Error during logout: {}", e.getMessage()); + // Don't throw exception for logout failures + } + } + + /** + * Permanently deletes member account and creates audit record + * @param member Authenticated member requesting deletion + * @param request Withdrawal request with reason + */ + @Transactional + public void deleteAccount(Member member, WithdrawalRequest request) { + // Validate member can be deleted + if (member.getStatus() == MemberStatus.WITHDRAWAL) { + throw new AuthException("Member already withdrawn", ErrorCode.MEMBER_WITHDRAWN); + } + + // Mark member as deleted (soft delete) + member.deleteAccount(); + + // Create audit record + WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( + request.withdrawalReason(), + member.getEmail(), + member.getNickname()); + withdrawalRecordWriter.store(withdrawalRecord); + + // Revoke all sessions + sessionManager.invalidateAllUserSessions(member.getId()); + + log.debug("Account deletion completed for member: {}", member.getEmail()); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java b/src/main/java/com/juu/juulabel/auth/service/AppleTokenService.java similarity index 54% rename from src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java rename to src/main/java/com/juu/juulabel/auth/service/AppleTokenService.java index 870650f..15a7c8d 100644 --- a/src/main/java/com/juu/juulabel/common/provider/token/jwt/AppleTokenProvider.java +++ b/src/main/java/com/juu/juulabel/auth/service/AppleTokenService.java @@ -1,4 +1,4 @@ -package com.juu.juulabel.common.provider.token.jwt; +package com.juu.juulabel.auth.service; import java.math.BigInteger; import java.nio.charset.StandardCharsets; @@ -9,8 +9,9 @@ import java.security.spec.RSAPublicKeySpec; import java.util.Base64; import java.util.List; +import java.util.function.Function; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -21,10 +22,20 @@ import com.juu.juulabel.member.request.AppleUser; import com.juu.juulabel.member.token.OAuthToken; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; -@Component -public class AppleTokenProvider extends JwtTokenProvider { +/** + * Service for handling Apple JWT token operations with improved architecture. + * Separates token parsing from business logic and provides better error handling. + */ +@Service +public class AppleTokenService { // Constants for better maintainability private static final String RSA_ALGORITHM = "RSA"; @@ -38,45 +49,96 @@ public class AppleTokenProvider extends JwtTokenProvider { // Reuse ObjectMapper instance for better performance private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public AppleTokenProvider() { - super(null, null); - } - /** - * Extracts Apple user information from JWT token using the provided public - * keys. + * Extracts Apple user information from JWT token using the provided public keys. * * @param publicKeys List of Apple's public keys * @param oauthToken OAuth token containing the ID token * @return AppleUser with extracted user information * @throws CustomJwtException if token processing fails */ - public AppleUser getAppleUserFromToken(List publicKeys, OAuthToken oauthToken) { - - ApplePublicKey applePublicKey = getApplePublicKey(publicKeys, oauthToken); - PublicKey publicKey = buildPublicKey(applePublicKey); + public AppleUser extractAppleUser(List publicKeys, OAuthToken oauthToken) { + ApplePublicKey applePublicKey = findMatchingPublicKey(publicKeys, oauthToken); + PublicKey publicKey = buildRSAPublicKey(applePublicKey); + JwtParser jwtParser = createJwtParser(publicKey); - // Set up JWT parser with the public key - super.key = publicKey; - super.jwtParser = Jwts.parser().verifyWith(publicKey).build(); + return extractFromClaims(oauthToken.idToken(), jwtParser, this::mapToAppleUser); + } - return extractFromClaims(oauthToken.idToken(), claims -> new AppleUser( - claims.get(SUB_CLAIM, String.class), - claims.get(EMAIL_CLAIM, String.class))); + /** + * Validates an Apple JWT token structure and signature. + * + * @param publicKeys List of Apple's public keys + * @param token JWT token string + * @return true if token is valid, false otherwise + */ + public boolean isValidAppleToken(List publicKeys, String token) { + try { + String kid = extractKidFromToken(token); + ApplePublicKey applePublicKey = findPublicKeyByKid(publicKeys, kid); + PublicKey publicKey = buildRSAPublicKey(applePublicKey); + JwtParser jwtParser = createJwtParser(publicKey); + + parseClaims(token, jwtParser); + return true; + } catch (Exception e) { + return false; + } } /** - * Finds the matching Apple public key based on the 'kid' in the JWT header. + * Extracts claims from Apple JWT token. * - * @param publicKeys List of available public keys - * @param oauthToken OAuth token containing the ID token - * @return Matching ApplePublicKey - * @throws CustomJwtException if no matching key is found or JWT processing - * fails + * @param publicKeys List of Apple's public keys + * @param token JWT token string + * @return parsed Claims */ - private ApplePublicKey getApplePublicKey(List publicKeys, OAuthToken oauthToken) { + public Claims extractClaims(List publicKeys, String token) { + String kid = extractKidFromToken(token); + ApplePublicKey applePublicKey = findPublicKeyByKid(publicKeys, kid); + PublicKey publicKey = buildRSAPublicKey(applePublicKey); + JwtParser jwtParser = createJwtParser(publicKey); + + return parseClaims(token, jwtParser); + } + + // Private helper methods + + private AppleUser mapToAppleUser(Claims claims) { + return new AppleUser( + claims.get(SUB_CLAIM, String.class), + claims.get(EMAIL_CLAIM, String.class) + ); + } + + private JwtParser createJwtParser(PublicKey publicKey) { + return Jwts.parser().verifyWith(publicKey).build(); + } + + private T extractFromClaims(String token, JwtParser jwtParser, Function claimsResolver) { + return claimsResolver.apply(parseClaims(token, jwtParser)); + } + + private Claims parseClaims(String token, JwtParser jwtParser) { + 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); + } + } + + private ApplePublicKey findMatchingPublicKey(List publicKeys, OAuthToken oauthToken) { String kid = extractKidFromToken(oauthToken.idToken()); + return findPublicKeyByKid(publicKeys, kid); + } + private ApplePublicKey findPublicKeyByKid(List publicKeys, String kid) { return publicKeys.stream() .filter(key -> kid.equals(key.kid())) .findFirst() @@ -85,13 +147,6 @@ private ApplePublicKey getApplePublicKey(List publicKeys, OAuthT ErrorCode.JWT_UNSUPPORTED_EXCEPTION)); } - /** - * Extracts the 'kid' (Key ID) from the JWT token header. - * - * @param idToken JWT ID token - * @return Key ID string - * @throws CustomJwtException if token parsing fails - */ private String extractKidFromToken(String idToken) { try { String[] chunks = idToken.split(JWT_SEPARATOR); @@ -120,14 +175,7 @@ private String extractKidFromToken(String idToken) { } } - /** - * Builds RSA public key from Apple's public key data. - * - * @param applePublicKey Apple public key containing modulus and exponent - * @return RSA PublicKey instance - * @throws CustomJwtException if key construction fails - */ - private PublicKey buildPublicKey(ApplePublicKey applePublicKey) { + private PublicKey buildRSAPublicKey(ApplePublicKey applePublicKey) { try { byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); @@ -150,5 +198,4 @@ private PublicKey buildPublicKey(ApplePublicKey applePublicKey) { ErrorCode.JWT_UNSUPPORTED_EXCEPTION); } } - -} +} \ 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 c1b1777..1d5121e 100644 --- a/src/main/java/com/juu/juulabel/auth/service/AuthService.java +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -2,84 +2,64 @@ import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; -import com.juu.juulabel.common.factory.OAuthProviderFactory; -import com.juu.juulabel.common.properties.RedirectProperties; -import com.juu.juulabel.common.provider.token.paseto.SignupTokenProvider; -import com.juu.juulabel.common.util.HttpResponseUtil; +import com.juu.juulabel.common.http.HttpResponseService; import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.MemberStatus; import com.juu.juulabel.member.domain.Provider; -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.WithdrawalRecordWriter; -import com.juu.juulabel.member.util.MemberUtils; -import com.juu.juulabel.redis.SessionManager; +import com.juu.juulabel.redis.UserSessionManager; +import com.juu.juulabel.auth.service.OAuthLoginService.MemberStatusResult; import io.sentry.Sentry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -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, - * logout, and account deletion. - * Provides secure OAuth-based authentication with session management. + * Refactored authentication service using specialized service components. + * Acts as an orchestration layer delegating to focused services. */ @Slf4j @Service @RequiredArgsConstructor public class AuthService { - private final MemberReader memberReader; - private final MemberWriter memberWriter; - private final WithdrawalRecordWriter withdrawalRecordWriter; - private final MemberUtils memberUtils; - private final OAuthProviderFactory providerFactory; - private final SessionManager sessionManager; - private final RedirectProperties redirectProperties; - private final SignupTokenProvider signupTokenProvider; - private final HttpResponseUtil httpResponseUtil; + private final OAuthLoginService oAuthLoginService; + private final MemberCreationService memberCreationService; + private final AccountLifecycleService accountLifecycleService; + private final SignupTokenService signupTokenService; + private final UserSessionManager sessionManager; + private final HttpResponseService httpResponseService; /** * Handles OAuth login flow for both new and existing members. * - * @param provider OAuth provider (Google, GitHub, etc.) + * @param provider OAuth provider (Google, Kakao, Apple) * @param code Authorization code from OAuth provider * @param state State parameter from OAuth provider */ @Transactional public void login(Provider provider, String code, String state) { try { - - // Get OAuth user info - OAuthUser oAuthUser = getOAuthUser(provider, code); - - // Process member based on existence and status - Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); - - if (memberOpt.isPresent()) { - Member member = memberOpt.get(); - if (member.getStatus() == MemberStatus.PENDING) { - handlePendingMember(member, oAuthUser); - } else { - handleExistingMember(member, oAuthUser); - } - } else { - handleNewMember(oAuthUser); + // Authenticate with OAuth provider + OAuthUser oAuthUser = oAuthLoginService.authenticateWithProvider(provider, code); + + // Determine member status and handle accordingly + MemberStatusResult memberResult = oAuthLoginService.determineMemberStatus(oAuthUser); + + if (memberResult.isNewMember()) { + handleNewMember(memberResult.oAuthUser()); + } else if (memberResult.isPendingMember()) { + handlePendingMember(memberResult.member(), memberResult.oAuthUser()); + } else if (memberResult.isActiveMember()) { + handleExistingMember(memberResult.member()); } } catch (Exception e) { + log.error("Login failed for provider {}: {}", provider, e.getMessage()); Sentry.captureException(e); - httpResponseUtil.redirectToError(); + httpResponseService.redirectToError(); } } @@ -91,108 +71,52 @@ public void login(Provider provider, String code, String state) { */ @Transactional public void signUp(Member member, SignUpMemberRequest signUpRequest) { - // Validate member status - if (member.getStatus() != MemberStatus.PENDING) { - throw new AuthException("Member is not in pending status", ErrorCode.INVALID_AUTHENTICATION); - } - - // Complete signup process - member.completeSignUp(signUpRequest); - memberWriter.store(member); - - // Process additional member data - memberUtils.processMemberData(member, signUpRequest); - - // Create session for the newly registered member - sessionManager.createSession(member); + Member completedMember = memberCreationService.completeSignup(member, signUpRequest); + sessionManager.createSession(completedMember); + + log.debug("Signup completed successfully for: {}", completedMember.getEmail()); } /** * Logs out current user by invalidating their session. */ public void logout() { - try { - sessionManager.invalidateSession(); - } catch (Exception e) { - log.warn("Error during logout: {}", e.getMessage()); - // Don't throw exception for logout failures - } + accountLifecycleService.logout(); } /** * Permanently deletes member account and creates audit record. * - * @param loginMember Authenticated member requesting deletion - * @param request Withdrawal request with reason + * @param member Authenticated member requesting deletion + * @param request Withdrawal request with reason */ @Transactional - public void deleteAccount(Member loginMember, WithdrawalRequest request) { - // Validate member can be deleted - if (loginMember.getStatus() == MemberStatus.WITHDRAWAL) { - throw new AuthException("Member already withdrawn", ErrorCode.MEMBER_WITHDRAWN); - } - - // Mark member as deleted (soft delete) - loginMember.deleteAccount(); - - // Create audit record - WithdrawalRecord withdrawalRecord = WithdrawalRecord.create( - request.withdrawalReason(), - loginMember.getEmail(), - loginMember.getNickname()); - withdrawalRecordWriter.store(withdrawalRecord); - - // Revoke all sessions - sessionManager.invalidateAllUserSessions(loginMember.getId()); - } - - // Private helper methods - - private OAuthUser getOAuthUser(Provider provider, String code) { - String redirectUrl = redirectProperties.getRedirectUrl(provider); - return providerFactory.getOAuthUser(provider, code, redirectUrl); + public void deleteAccount(Member member, WithdrawalRequest request) { + accountLifecycleService.deleteAccount(member, request); } - private void handleExistingMember(Member member, OAuthUser oAuthUser) { - // Validate member status - if (member.getStatus() == MemberStatus.WITHDRAWAL) { - throw new AuthException("Member has been withdrawn", ErrorCode.MEMBER_WITHDRAWN); - } - - if (member.getStatus() == MemberStatus.INACTIVE) { - throw new AuthException("Member is not active", ErrorCode.MEMBER_NOT_ACTIVE); - } - - // Validate OAuth user matches member - member.validateLoginMember(oAuthUser); + // Private helper methods for different member handling scenarios - // Create session and redirect - sessionManager.createSession(member); - httpResponseUtil.redirectToLogin(); + private void handleNewMember(OAuthUser oAuthUser) { + String nonce = memberCreationService.createPendingMember(oAuthUser); + signupTokenService.createToken(oAuthUser, nonce); + httpResponseService.redirectToSignup(); + + log.debug("New member flow initiated for: {}", oAuthUser.email()); } private void handlePendingMember(Member member, OAuthUser oAuthUser) { - // Validate OAuth user matches pending member - member.validateLoginMember(oAuthUser); - - // Generate new signup token for existing pending member - String nonce = member.getNickname(); // Use existing nonce - signupTokenProvider.createToken(oAuthUser, nonce); - - httpResponseUtil.redirectToSignup(); + String nonce = memberCreationService.getExistingNonce(member); + signupTokenService.createToken(oAuthUser, nonce); + httpResponseService.redirectToSignup(); + + log.debug("Pending member flow initiated for: {}", oAuthUser.email()); } - private void handleNewMember(OAuthUser oAuthUser) { - // Generate unique nonce for new member - String nonce = UUID.randomUUID().toString(); - - // Create signup token - signupTokenProvider.createToken(oAuthUser, nonce); - - // Create new pending member - Member newMember = Member.create(oAuthUser, nonce); - memberWriter.store(newMember); - - httpResponseUtil.redirectToSignup(); + private void handleExistingMember(Member member) { + sessionManager.createSession(member); + httpResponseService.redirectToLogin(); + + log.debug("Existing member login successful for: {}", member.getEmail()); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java b/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java new file mode 100644 index 0000000..5c099a0 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java @@ -0,0 +1,89 @@ +package com.juu.juulabel.auth.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.repository.MemberWriter; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.util.MemberUtils; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service dedicated to member creation and signup operations. + * Handles new member creation and signup completion. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberCreationService { + + private final MemberWriter memberWriter; + private final MemberUtils memberUtils; + + /** + * Creates a new pending member from OAuth user data + * @param oAuthUser OAuth user information + * @return Generated nonce for the new member + */ + @Transactional + public String createPendingMember(OAuthUser oAuthUser) { + // Generate unique nonce for new member + String nonce = UUID.randomUUID().toString(); + + // Create new pending member + Member newMember = Member.create(oAuthUser, nonce); + memberWriter.store(newMember); + + log.debug("Created new pending member for email: {} with nonce: {}", + oAuthUser.email(), nonce); + + return nonce; + } + + /** + * Completes member signup with additional information + * @param member Pre-authenticated member from signup token + * @param signUpRequest Additional member registration details + * @return The completed member + */ + @Transactional + public Member completeSignup(Member member, SignUpMemberRequest signUpRequest) { + // Validate member status + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member is not in pending status", ErrorCode.INVALID_AUTHENTICATION); + } + + // Complete signup process + member.completeSignUp(signUpRequest); + memberWriter.store(member); + + // Process additional member data + memberUtils.processMemberData(member, signUpRequest); + + log.debug("Completed signup for member: {}", member.getEmail()); + + return member; + } + + /** + * Gets existing nonce for pending member (used for existing pending members) + * @param member Existing pending member + * @return The member's existing nonce + */ + public String getExistingNonce(Member member) { + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member is not in pending status", ErrorCode.INVALID_AUTHENTICATION); + } + + return member.getNickname(); // The nonce is stored in nickname for pending members + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java b/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java new file mode 100644 index 0000000..d4b17fe --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java @@ -0,0 +1,105 @@ +package com.juu.juulabel.auth.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.factory.OAuthProviderFactory; +import com.juu.juulabel.common.properties.RedirectProperties; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.request.OAuthUser; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service dedicated to OAuth authentication flow. + * Handles OAuth provider interactions and user data retrieval. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthLoginService { + + private final OAuthProviderFactory providerFactory; + private final RedirectProperties redirectProperties; + private final MemberReader memberReader; + + /** + * Performs OAuth authentication and returns user info + * @param provider OAuth provider + * @param code Authorization code + * @return OAuth user information + */ + public OAuthUser authenticateWithProvider(Provider provider, String code) { + try { + String redirectUrl = redirectProperties.getRedirectUrl(provider); + OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, code, redirectUrl); + + log.debug("OAuth authentication successful for provider: {} email: {}", + provider, oAuthUser.email()); + return oAuthUser; + + } catch (Exception e) { + log.error("OAuth authentication failed for provider: {} - {}", provider, e.getMessage()); + throw new AuthException("OAuth authentication failed", ErrorCode.INVALID_AUTHENTICATION); + } + } + + /** + * Determines the member status for OAuth user + * @param oAuthUser OAuth user information + * @return Member status result + */ + public MemberStatusResult determineMemberStatus(OAuthUser oAuthUser) { + Optional memberOpt = memberReader.getOptionalByEmail(oAuthUser.email()); + + if (memberOpt.isEmpty()) { + return new MemberStatusResult(null, null, oAuthUser, true); + } + + Member member = memberOpt.get(); + validateMemberForLogin(member, oAuthUser); + + return new MemberStatusResult(member.getStatus(), member, oAuthUser, false); + } + + /** + * Validates that the member can login with the OAuth user + */ + private void validateMemberForLogin(Member member, OAuthUser oAuthUser) { + if (member.getStatus() == MemberStatus.WITHDRAWAL) { + throw new AuthException("Member has been withdrawn", ErrorCode.MEMBER_WITHDRAWN); + } + + if (member.getStatus() == MemberStatus.INACTIVE) { + throw new AuthException("Member is not active", ErrorCode.MEMBER_NOT_ACTIVE); + } + + // Validate OAuth user matches member + member.validateLoginMember(oAuthUser); + } + + /** + * Result object containing member status and related data + */ + public record MemberStatusResult( + MemberStatus status, + Member member, + OAuthUser oAuthUser, + boolean isNewMember) { + + public boolean isPendingMember() { + return status == MemberStatus.PENDING; + } + + public boolean isActiveMember() { + return status == MemberStatus.ACTIVE; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java b/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java new file mode 100644 index 0000000..93a878e --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java @@ -0,0 +1,152 @@ +package com.juu.juulabel.auth.service; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.http.CookieService; +import com.juu.juulabel.common.provider.token.paseto.PasetoTokenService; +import com.juu.juulabel.common.provider.token.validator.SignupTokenClaims; +import com.juu.juulabel.common.provider.token.validator.SignupTokenValidator; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.request.OAuthUser; + +import dev.paseto.jpaseto.Claims; + +/** + * Service for handling signup token operations with business logic. + * Extends PasetoTokenService to inherit PASETO-specific token operations. + */ +@Service +public class SignupTokenService extends PasetoTokenService { + + private static final String AUDIENCE_CLAIM = "user-signup-completion"; + private static final String EMAIL_CLAIM = "email"; + private static final String PROVIDER_CLAIM = "provider"; + private static final String PROVIDER_ID_CLAIM = "providerId"; + private static final String NONCE_CLAIM = "nonce"; + private static final String AUDIENCE_CLAIM_KEY = "aud"; + + private final SignupTokenValidator validator; + private final MemberReader memberReader; + private final CookieService cookieService; + + public SignupTokenService( + @Value("${app.paseto.sign-up-key}") String secretKey, + SignupTokenValidator validator, + MemberReader memberReader, + CookieService cookieService) { + + super(secretKey, AuthConstants.SIGN_UP_TOKEN_DURATION); + this.validator = validator; + this.memberReader = memberReader; + this.cookieService = cookieService; + } + + /** + * Creates and sets signup token as HTTP-only cookie + */ + public void createAndSetToken(OAuthUser oAuthUser, String nonce) { + Map claims = Map.of( + EMAIL_CLAIM, oAuthUser.email(), + PROVIDER_CLAIM, oAuthUser.provider().name(), + PROVIDER_ID_CLAIM, oAuthUser.id(), + NONCE_CLAIM, nonce, + AUDIENCE_CLAIM_KEY, AUDIENCE_CLAIM); + + String token = createToken(claims); + cookieService.addCookie( + AuthConstants.SIGN_UP_TOKEN_NAME, + token, + (int) AuthConstants.SIGN_UP_TOKEN_DURATION.toSeconds()); + } + + /** + * Verifies token and returns authenticated member + */ + public Authentication getAuthentication(String token) { + Member member = verifyTokenAndGetMember(token); + return new UsernamePasswordAuthenticationToken(member, null, Collections.emptyList()); + } + + /** + * Verifies signup token and returns the associated member + */ + public Member verifyTokenAndGetMember(String token) { + // Parse token claims (inherited from PasetoTokenService) + Claims claims = parseToken(token); + + // Convert to map and then to structured claims + Map claimsMap = convertClaimsToMap(claims); + SignupTokenClaims signupClaims = SignupTokenClaims.from(claimsMap); + + // Validate claims + validator.validate(signupClaims); + + // Return validated member + return memberReader.getByEmail(signupClaims.email()); + } + + /** + * Resolves token from header by removing token prefix + * This method maintains compatibility with the existing TokenProvider interface + */ + public String resolveToken(String header) { + if (!org.springframework.util.StringUtils.hasText(header)) { + throw new com.juu.juulabel.common.exception.InvalidParamException( + com.juu.juulabel.common.exception.code.ErrorCode.INVALID_AUTHENTICATION); + } + return header.replace(AuthConstants.TOKEN_PREFIX, ""); + } + + /** + * Creates signup token and sets it as cookie + * This method name maintains compatibility with the existing + * SignupTokenProvider + */ + public void createToken(OAuthUser oAuthUser, String nonce) { + createAndSetToken(oAuthUser, nonce); + } + + /** + * Converts PASETO Claims to Map for easier processing + */ + private Map convertClaimsToMap(Claims claims) { + Map claimsMap = new HashMap<>(); + + // Extract standard PASETO claims + if (claims.getSubject() != null) { + claimsMap.put("sub", claims.getSubject()); + } + if (claims.getAudience() != null) { + claimsMap.put("aud", claims.getAudience()); + } + if (claims.getIssuer() != null) { + claimsMap.put("iss", claims.getIssuer()); + } + if (claims.getIssuedAt() != null) { + claimsMap.put("iat", claims.getIssuedAt()); + } + if (claims.getExpiration() != null) { + claimsMap.put("exp", claims.getExpiration()); + } + if (claims.getNotBefore() != null) { + claimsMap.put("nbf", claims.getNotBefore()); + } + if (claims.getTokenId() != null) { + claimsMap.put("jti", claims.getTokenId()); + } + + // Extract custom claims + claims.forEach(claimsMap::put); + + return claimsMap; + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategy.java b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategy.java new file mode 100644 index 0000000..bcf926f --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategy.java @@ -0,0 +1,36 @@ +package com.juu.juulabel.common.auth; + +import org.springframework.security.core.Authentication; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Strategy interface for different authentication types. + * Allows the authorization filter to handle different auth flows cleanly. + */ +public interface AuthenticationStrategy { + + /** + * Checks if this strategy can handle the current request + */ + boolean canHandle(HttpServletRequest request); + + /** + * Creates authentication from the request + * @param request HTTP request containing authentication data + * @return Authentication object or null if not authenticated + */ + Authentication authenticate(HttpServletRequest request); + + /** + * Returns the priority order of this strategy (lower = higher priority) + */ + default int getOrder() { + return 100; + } + + /** + * Returns the name of this authentication strategy for logging + */ + String getStrategyName(); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java new file mode 100644 index 0000000..076d666 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java @@ -0,0 +1,83 @@ +package com.juu.juulabel.common.auth; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolves and applies appropriate authentication strategy for incoming + * requests. + * Coordinates multiple authentication strategies in priority order. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthenticationStrategyResolver { + + private final List strategies; + + /** + * Resolves authentication for the given request using available strategies + * + * @param request HTTP request to authenticate + * @return Authentication object or null if no authentication applies + */ + public Optional resolveAuthentication(HttpServletRequest request) { + // Skip OPTIONS requests + if ("OPTIONS".equals(request.getMethod())) { + log.trace("Skipping authentication for OPTIONS request: {}", request.getRequestURI()); + return Optional.empty(); + } + + // Find the first strategy that can handle this request (sorted by priority) + Optional applicableStrategy = strategies.stream() + .sorted(Comparator.comparingInt(AuthenticationStrategy::getOrder)) + .filter(strategy -> strategy.canHandle(request)) + .findFirst(); + + if (applicableStrategy.isEmpty()) { + log.trace("No authentication strategy found for request: {}", request.getRequestURI()); + return Optional.empty(); + } + + AuthenticationStrategy strategy = applicableStrategy.get(); + + try { + log.debug("Using {} strategy for request: {}", + strategy.getStrategyName(), request.getRequestURI()); + + Authentication authentication = strategy.authenticate(request); + + if (authentication != null) { + log.debug("Authentication successful using {} strategy for: {}", + strategy.getStrategyName(), authentication.getName()); + } else { + log.debug("No authentication provided by {} strategy", strategy.getStrategyName()); + } + + return Optional.ofNullable(authentication); + + } catch (Exception e) { + log.warn("Authentication failed using {} strategy for {}: {}", + strategy.getStrategyName(), request.getRequestURI(), e.getMessage()); + throw e; // Re-throw to let filter handle the exception + } + } + + /** + * Returns the list of available authentication strategies for debugging + */ + public List getAvailableStrategies() { + return strategies.stream() + .sorted(Comparator.comparingInt(AuthenticationStrategy::getOrder)) + .map(AuthenticationStrategy::getStrategyName) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/SignupTokenAuthenticationStrategy.java b/src/main/java/com/juu/juulabel/common/auth/SignupTokenAuthenticationStrategy.java new file mode 100644 index 0000000..0cbafa3 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/SignupTokenAuthenticationStrategy.java @@ -0,0 +1,68 @@ +package com.juu.juulabel.common.auth; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.auth.service.SignupTokenService; +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.http.CookieService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Authentication strategy for signup token validation. + * Handles requests to signup endpoints that require signup token. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SignupTokenAuthenticationStrategy implements AuthenticationStrategy { + + private static final String SIGNUP_PATH_PREFIX = "/v1/api/auth/sign-up"; + + private final SignupTokenService signupTokenService; + private final CookieService cookieService; + + @Override + public boolean canHandle(HttpServletRequest request) { + return request.getRequestURI().startsWith(SIGNUP_PATH_PREFIX); + } + + @Override + public Authentication authenticate(HttpServletRequest request) { + String signupToken = cookieService.getCookie(AuthConstants.SIGN_UP_TOKEN_NAME) + .orElse(null); + + if (signupToken == null || signupToken.trim().isEmpty()) { + log.warn("Signup token missing for signup request: {}", request.getRequestURI()); + throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); + } + + try { + String token = signupTokenService.resolveToken(signupToken); + Authentication authentication = signupTokenService.getAuthentication(token); + + log.debug("Signup token authentication successful for: {}", + authentication.getName()); + return authentication; + + } catch (Exception e) { + log.warn("Signup token validation failed: {}", e.getMessage()); + throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); + } + } + + @Override + public int getOrder() { + return 10; // High priority for signup requests + } + + @Override + public String getStrategyName() { + return "SignupToken"; + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/UserSessionAuthenticationStrategy.java b/src/main/java/com/juu/juulabel/common/auth/UserSessionAuthenticationStrategy.java new file mode 100644 index 0000000..0df1306 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/auth/UserSessionAuthenticationStrategy.java @@ -0,0 +1,75 @@ +package com.juu.juulabel.common.auth; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.http.CookieService; +import com.juu.juulabel.redis.UserSessionManager; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Authentication strategy for user session validation. + * Handles regular authenticated requests using session cookies. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserSessionAuthenticationStrategy implements AuthenticationStrategy { + + private final UserSessionManager sessionManager; + private final CookieService cookieService; + + @Override + public boolean canHandle(HttpServletRequest request) { + // This strategy handles any request with a session token + // (but lower priority than signup token strategy) + return cookieService.getCookie(AuthConstants.AUTH_TOKEN_NAME).isPresent(); + } + + @Override + public Authentication authenticate(HttpServletRequest request) { + String authToken = cookieService.getCookie(AuthConstants.AUTH_TOKEN_NAME) + .orElse(null); + + if (authToken == null || authToken.trim().isEmpty()) { + log.debug("No auth token found for request: {}", request.getRequestURI()); + return null; // Not an error - just no authentication + } + + try { + Authentication authentication = sessionManager.getAuthentication(authToken); + log.debug("Session authentication successful for: {}", + authentication.getName()); + return authentication; + + } catch (Exception e) { + log.warn("Session authentication failed for token: {} - {}", + maskToken(authToken), e.getMessage()); + return null; // Don't throw exception - let request proceed unauthenticated + } + } + + @Override + public int getOrder() { + return 50; // Lower priority than signup token + } + + @Override + public String getStrategyName() { + return "UserSession"; + } + + /** + * Masks sensitive token for logging + */ + private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java b/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java index ea56cac..a9013e9 100644 --- a/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/AuthExceptionFilter.java @@ -31,10 +31,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse log.warn("Authentication failed for request {}: {}", request.getRequestURI(), ex.getMessage()); securityResponseUtil.setErrorResponse(response, HttpStatus.BAD_REQUEST, ex); } catch (Exception ex) { - log.error("Unexpected exception in auth filter for request {}: {}", - request.getRequestURI(), ex.getMessage()); - securityResponseUtil.setErrorResponse(response, HttpStatus.UNAUTHORIZED, - ErrorCode.INVALID_AUTHENTICATION, ex.getMessage()); + log.error("Unexpected exception in auth filter for request {}: {}", + request.getRequestURI(), ex.getMessage()); + securityResponseUtil.setErrorResponse(response, HttpStatus.UNAUTHORIZED, + ErrorCode.INVALID_AUTHENTICATION, ex.getMessage()); } } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java index 25742c0..f21a2f9 100644 --- a/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/AuthorizationFilter.java @@ -1,14 +1,9 @@ package com.juu.juulabel.common.filter; import com.fasterxml.jackson.databind.ObjectMapper; -import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.auth.AuthenticationStrategyResolver; import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.util.CookieUtil; -import com.juu.juulabel.common.provider.token.paseto.SignupTokenProvider; import com.juu.juulabel.common.response.CommonResponse; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.redis.SessionManager; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -16,29 +11,29 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.List; +import java.util.Optional; +/** + * Improved authorization filter using strategy pattern. + * Delegates authentication logic to specialized strategies. + */ @Slf4j @Component @RequiredArgsConstructor public class AuthorizationFilter extends OncePerRequestFilter { - private static final String SIGNUP_PATH_PREFIX = "/v1/api/auth/sign-up"; private static final String UTF_8 = "UTF-8"; - private static final List ALLOWED_METHODS = List.of("OPTIONS"); - private final SignupTokenProvider signUpTokenProvider; - private final SessionManager sessionManager; - private final CookieUtil cookieUtil; + private final AuthenticationStrategyResolver strategyResolver; private final ObjectMapper objectMapper; @Override @@ -46,59 +41,50 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throws ServletException, IOException { try { - if (isSignUpRequest()) { - handleSignUpRequest(); - } else { - if (!ALLOWED_METHODS.contains(request.getMethod())) { - handleRegularRequest(); - } - } + // Use strategy resolver to determine and apply authentication + Optional authenticationOpt = strategyResolver.resolveAuthentication(request); + + // Set authentication in security context if present + authenticationOpt.ifPresent(authentication -> { + SecurityContextHolder.getContext().setAuthentication(authentication); + log.trace("Authentication set in security context for: {}", authentication.getName()); + }); + } catch (AuthException e) { + log.warn("Authentication failed for request {}: {}", request.getRequestURI(), e.getMessage()); handleAuthException(response, e); return; + } catch (Exception e) { + log.error("Unexpected exception during authentication for {}: {}", + request.getRequestURI(), e.getMessage()); + handleGenericException(response, e); + return; } + // Continue with the filter chain filterChain.doFilter(request, response); } - private boolean isSignUpRequest() { - return HttpRequestUtil.isPathMatch(SIGNUP_PATH_PREFIX); - } - - private void handleSignUpRequest() { - String signupToken = cookieUtil.getCookie(AuthConstants.SIGN_UP_TOKEN_NAME); - - if (!StringUtils.hasText(signupToken)) { - throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED); - } - - processSignUpToken(signupToken); - } - - private void handleRegularRequest() { - String authToken = cookieUtil.getCookie(AuthConstants.AUTH_TOKEN_NAME); - - if (StringUtils.hasText(authToken)) { - processUserSession(authToken); - } - } - - private void processSignUpToken(String signupToken) { - String token = signUpTokenProvider.resolveToken(signupToken); - Authentication authentication = signUpTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - private void processUserSession(String authToken) { - Authentication authentication = sessionManager.getAuthentication(authToken); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - + /** + * Handles authentication exceptions with proper error response + */ private void handleAuthException(HttpServletResponse response, AuthException e) throws IOException { writeErrorResponse(response, HttpStatus.UNAUTHORIZED, CommonResponse.fail(e.getErrorCode(), e.getMessage()).getBody()); } + /** + * Handles unexpected exceptions with generic error response + */ + private void handleGenericException(HttpServletResponse response, Exception e) throws IOException { + writeErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, + CommonResponse.fail(com.juu.juulabel.common.exception.code.ErrorCode.INTERNAL_SERVER_ERROR, + "Authentication error").getBody()); + } + + /** + * Writes standardized error response + */ private void writeErrorResponse(HttpServletResponse response, HttpStatus status, CommonResponse errorResponse) throws IOException { response.setCharacterEncoding(UTF_8); diff --git a/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java b/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java index f1e4b11..bf4904e 100644 --- a/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java +++ b/src/main/java/com/juu/juulabel/common/handler/CustomAccessDeniedHandler.java @@ -10,13 +10,9 @@ import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.security.web.csrf.InvalidCsrfTokenException; -import org.springframework.security.web.csrf.MissingCsrfTokenException; -import org.springframework.security.web.csrf.CsrfException; import org.springframework.stereotype.Component; import java.io.IOException; -import java.util.Map; @Slf4j @Component @@ -25,33 +21,14 @@ public class CustomAccessDeniedHandler implements AccessDeniedHandler { private final SecurityResponseUtil securityResponseUtil; - // Map exception types to error codes for better performance - private static final Map, ErrorCode> CSRF_ERROR_MAP = Map.of( - InvalidCsrfTokenException.class, ErrorCode.CSRF_TOKEN_INVALID, - MissingCsrfTokenException.class, ErrorCode.CSRF_TOKEN_MISSING, - CsrfException.class, ErrorCode.CSRF_TOKEN_MISMATCH); - @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { String requestInfo = String.format("%s %s", request.getMethod(), request.getRequestURI()); - log.warn("Access denied for request: {} - Exception: {}", - requestInfo, accessDeniedException.getClass().getSimpleName()); - // Handle CSRF exceptions with optimized lookup - ErrorCode csrfErrorCode = CSRF_ERROR_MAP.get(accessDeniedException.getClass()); - if (csrfErrorCode != null) { - handleCsrfException(response, csrfErrorCode, accessDeniedException.getMessage(), requestInfo); - } else { - handleAccessDenied(response, accessDeniedException.getMessage(), requestInfo); - } - } - private void handleCsrfException(HttpServletResponse response, ErrorCode errorCode, - String message, String requestInfo) throws IOException { - log.warn("CSRF token validation failed for {}: {}", requestInfo, message); - securityResponseUtil.setErrorResponse(response, HttpStatus.FORBIDDEN, errorCode, message); + handleAccessDenied(response, accessDeniedException.getMessage(), requestInfo); } private void handleAccessDenied(HttpServletResponse response, String message, diff --git a/src/main/java/com/juu/juulabel/common/http/CookieService.java b/src/main/java/com/juu/juulabel/common/http/CookieService.java new file mode 100644 index 0000000..1cff164 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/CookieService.java @@ -0,0 +1,176 @@ +package com.juu.juulabel.common.http; + +import java.util.Arrays; +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.properties.CookieProperties; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for secure cookie management operations. + * Provides methods for creating, retrieving, and removing HTTP cookies with + * security best practices. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CookieService { + + // Cookie removal configuration + private static final int COOKIE_REMOVAL_MAX_AGE = 0; + private static final String EMPTY_VALUE = ""; + + private final HttpContextService httpContextService; + private final CookieProperties cookieProperties; + + /** + * Retrieves a cookie value by name from the current HTTP request + * @param name the cookie name to search for + * @return the cookie value if found, null otherwise + */ + public Optional getCookie(String name) { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getCookies) + .map(cookies -> findCookieByName(cookies, name)) + .orElse(Optional.empty()); + } + + /** + * Adds a secure HTTP-only cookie to the response with comprehensive security + * settings + * @param name the cookie name + * @param value the cookie value + * @param maxAge the cookie max age in seconds + */ + public void addCookie(String name, String value, int maxAge) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + Cookie cookie = createSecureCookie(name, value, maxAge); + response.addCookie(cookie); + log.debug("Added secure cookie: {} with maxAge: {}", name, maxAge); + }, + () -> log.warn("Cannot add cookie '{}' - no HTTP response context available", name) + ); + } + + /** + * Removes a cookie by setting its max age to 0 and clearing its value. + * This method ensures proper cookie removal across different browsers. + * @param name the cookie name to remove + */ + public void removeCookie(String name) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + // Create removal cookie with both secure and non-secure variants + // to ensure removal regardless of original cookie settings + Cookie removeCookie = createRemovalCookie(name, false); + response.addCookie(removeCookie); + + // Also add secure variant for removal + Cookie secureRemoveCookie = createRemovalCookie(name, true); + response.addCookie(secureRemoveCookie); + + log.debug("Removed cookie: {}", name); + }, + () -> log.warn("Cannot remove cookie '{}' - no HTTP response context available", name) + ); + } + + /** + * Checks if a cookie with the given name exists in the current request + * @param name the cookie name to check + * @return true if cookie exists, false otherwise + */ + public boolean cookieExists(String name) { + return getCookie(name).isPresent(); + } + + /** + * Gets all cookies from the current request + * @return array of cookies, or empty array if none exist + */ + public Cookie[] getAllCookies() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getCookies) + .orElse(new Cookie[0]); + } + + /** + * Validates cookie name according to RFC standards + * @param name cookie name to validate + * @return true if valid cookie name + */ + public boolean isValidCookieName(String name) { + if (name == null || name.trim().isEmpty()) { + return false; + } + + // Basic validation - no spaces, control characters, or special chars + return name.matches("^[a-zA-Z0-9_-]+$"); + } + + /** + * Creates a secure cookie with comprehensive security settings + */ + private Cookie createSecureCookie(String name, String value, int maxAge) { + boolean isSecure = cookieProperties.isSecure(); + Cookie cookie = new Cookie(name, value); + + // Set domain only for production/secure environments + if (isSecure) { + cookie.setDomain(cookieProperties.getDomain()); + } + + cookie.setPath(cookieProperties.getPath()); + cookie.setHttpOnly(cookieProperties.isHttpOnly()); + cookie.setSecure(isSecure); + cookie.setMaxAge(maxAge); + + // Set SameSite attribute based on security requirements + String sameSite = isSecure ? + cookieProperties.getSameSiteSecure() : + cookieProperties.getSameSiteNonSecure(); + cookie.setAttribute("SameSite", sameSite); + + return cookie; + } + + /** + * Creates a cookie specifically for removal purposes + */ + private Cookie createRemovalCookie(String name, boolean isSecure) { + Cookie cookie = new Cookie(name, EMPTY_VALUE); + + if (isSecure) { + cookie.setDomain(cookieProperties.getDomain()); + cookie.setSecure(true); + } + + cookie.setPath(cookieProperties.getPath()); + cookie.setHttpOnly(cookieProperties.isHttpOnly()); + cookie.setMaxAge(COOKIE_REMOVAL_MAX_AGE); + + return cookie; + } + + /** + * Helper method to find cookie by name in cookie array + */ + private Optional findCookieByName(Cookie[] cookies, String name) { + if (cookies == null) { + log.debug("No cookies found in request"); + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> name.equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/http/HttpContextService.java b/src/main/java/com/juu/juulabel/common/http/HttpContextService.java new file mode 100644 index 0000000..7f69eb5 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/HttpContextService.java @@ -0,0 +1,94 @@ +package com.juu.juulabel.common.http; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for accessing HTTP context (request/response) from the current thread. + * Provides safe access to servlet objects with proper error handling. + */ +@Slf4j +@Service +public class HttpContextService { + + /** + * Gets the current HTTP request from the servlet context + * @return the current HttpServletRequest + * @throws BaseException if request context is not available + */ + public HttpServletRequest getCurrentRequest() { + return getServletRequestAttributes() + .map(ServletRequestAttributes::getRequest) + .orElseThrow(() -> { + log.error("No HTTP request context available"); + return new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + }); + } + + /** + * Gets the current HTTP response from the servlet context + * @return the current HttpServletResponse + * @throws BaseException if request context is not available + */ + public HttpServletResponse getCurrentResponse() { + return getServletRequestAttributes() + .map(ServletRequestAttributes::getResponse) + .orElseThrow(() -> { + log.error("No HTTP response context available"); + return new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + }); + } + + /** + * Safely gets the current request if available + * @return Optional containing the request, or empty if not available + */ + public Optional getCurrentRequestOptional() { + try { + return Optional.of(getCurrentRequest()); + } catch (BaseException e) { + log.debug("HTTP request context not available: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Safely gets the current response if available + * @return Optional containing the response, or empty if not available + */ + public Optional getCurrentResponseOptional() { + try { + return Optional.of(getCurrentResponse()); + } catch (BaseException e) { + log.debug("HTTP response context not available: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Checks if HTTP context is currently available + * @return true if context is available, false otherwise + */ + public boolean isContextAvailable() { + return getServletRequestAttributes().isPresent(); + } + + /** + * Gets ServletRequestAttributes safely + */ + private Optional getServletRequestAttributes() { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/http/HttpResponseService.java b/src/main/java/com/juu/juulabel/common/http/HttpResponseService.java new file mode 100644 index 0000000..6195d4a --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/HttpResponseService.java @@ -0,0 +1,232 @@ +package com.juu.juulabel.common.http; + +import java.io.IOException; +import java.util.Optional; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.properties.RedirectProperties; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for HTTP response operations. + * Handles redirects, status codes, headers, and response manipulation. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HttpResponseService { + + private final HttpContextService httpContextService; + private final RedirectProperties redirectProperties; + + /** + * Redirects to the configured login URL + */ + public void redirectToLogin() { + redirect(redirectProperties.getLoginUrl()); + log.debug("Redirected to login page"); + } + + /** + * Redirects to the configured signup URL + */ + public void redirectToSignup() { + redirect(redirectProperties.getSignupUrl()); + log.debug("Redirected to signup page"); + } + + /** + * Redirects to the configured error URL + */ + public void redirectToError() { + redirect(redirectProperties.getErrorUrl()); + log.debug("Redirected to error page"); + } + + /** + * Performs redirect to specified URL + * @param url Target URL for redirect + * @throws BaseException if redirect fails or no response context available + */ + public void redirect(String url) { + if (url == null || url.trim().isEmpty()) { + log.error("Redirect URL is null or empty"); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + try { + response.sendRedirect(url); + log.debug("Successfully redirected to: {}", url); + } catch (IOException e) { + log.error("Failed to redirect to URL: {} - {}", url, e.getMessage()); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + }, + () -> { + log.error("Cannot redirect to '{}' - no HTTP response context available", url); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + ); + } + + /** + * Sets response status code + * @param status HTTP status to set + */ + public void setStatus(HttpStatus status) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.setStatus(status.value()); + log.debug("Set response status to: {}", status); + }, + () -> log.warn("Cannot set status '{}' - no HTTP response context available", status) + ); + } + + /** + * Adds header to response + * @param name Header name + * @param value Header value + */ + public void addHeader(String name, String value) { + if (name == null || name.trim().isEmpty()) { + log.warn("Header name is null or empty"); + return; + } + + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.addHeader(name, value); + log.debug("Added header: {} = {}", name, value); + }, + () -> log.warn("Cannot add header '{}' - no HTTP response context available", name) + ); + } + + /** + * Sets content type for response + * @param contentType Content type to set + */ + public void setContentType(String contentType) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.setContentType(contentType); + log.debug("Set content type to: {}", contentType); + }, + () -> log.warn("Cannot set content type '{}' - no HTTP response context available", contentType) + ); + } + + /** + * Sets content type using MediaType enum + * @param mediaType MediaType to set + */ + public void setContentType(MediaType mediaType) { + setContentType(mediaType.toString()); + } + + /** + * Sets character encoding for response + * @param encoding Character encoding to set + */ + public void setCharacterEncoding(String encoding) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + response.setCharacterEncoding(encoding); + log.debug("Set character encoding to: {}", encoding); + }, + () -> log.warn("Cannot set character encoding '{}' - no HTTP response context available", encoding) + ); + } + + /** + * Writes content to response + * @param content Content to write + * @throws BaseException if writing fails + */ + public void writeContent(String content) { + httpContextService.getCurrentResponseOptional().ifPresentOrElse( + response -> { + try { + response.getWriter().write(content); + log.debug("Successfully wrote content to response (length: {})", content.length()); + } catch (IOException e) { + log.error("Failed to write content to response: {}", e.getMessage()); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + }, + () -> { + log.error("Cannot write content - no HTTP response context available"); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + ); + } + + /** + * Sets cache control headers to prevent caching + */ + public void setNoCache() { + addHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); + addHeader(HttpHeaders.PRAGMA, "no-cache"); + addHeader(HttpHeaders.EXPIRES, "0"); + log.debug("Set no-cache headers"); + } + + /** + * Sets cache control headers for specified max age + * @param maxAgeSeconds Maximum age in seconds + */ + public void setCacheMaxAge(int maxAgeSeconds) { + addHeader(HttpHeaders.CACHE_CONTROL, "max-age=" + maxAgeSeconds); + log.debug("Set cache max-age to: {} seconds", maxAgeSeconds); + } + + /** + * Checks if response is committed (headers already sent) + * @return true if response is committed + */ + public boolean isCommitted() { + return httpContextService.getCurrentResponseOptional() + .map(HttpServletResponse::isCommitted) + .orElse(true); // Assume committed if no context + } + + /** + * Gets current response status if available + * @return Optional containing status code + */ + public Optional getStatus() { + return httpContextService.getCurrentResponseOptional() + .map(HttpServletResponse::getStatus); + } + + /** + * Safely redirects with fallback error handling + * @param url Target URL + * @param fallbackUrl Fallback URL if primary fails + */ + public void safeRedirect(String url, String fallbackUrl) { + try { + redirect(url); + } catch (Exception e) { + log.warn("Primary redirect to '{}' failed, trying fallback: {}", url, e.getMessage()); + try { + redirect(fallbackUrl); + } catch (Exception fallbackException) { + log.error("Both primary and fallback redirects failed: {}", fallbackException.getMessage()); + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java b/src/main/java/com/juu/juulabel/common/http/IpAddressService.java similarity index 74% rename from src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java rename to src/main/java/com/juu/juulabel/common/http/IpAddressService.java index 4f679bc..be1498a 100644 --- a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java +++ b/src/main/java/com/juu/juulabel/common/http/IpAddressService.java @@ -1,17 +1,29 @@ -package com.juu.juulabel.common.util; +package com.juu.juulabel.common.http; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import java.net.InetAddress; import java.net.UnknownHostException; import java.util.List; +import java.util.Optional; import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + /** - * Utility class for IP address extraction and validation + * Service for IP address extraction and validation. + * Handles reliable client IP detection with validation and reliability scoring. */ -public final class IpAddressExtractor extends AbstractHttpUtil { +@Slf4j +@Service +@RequiredArgsConstructor +public class IpAddressService { private static final String UNKNOWN = "unknown"; + + private final HttpContextService httpContextService; // Ordered by reliability - most trusted first private static final List IP_HEADER_CANDIDATES = List.of( @@ -39,27 +51,25 @@ public final class IpAddressExtractor extends AbstractHttpUtil { private static final Pattern IPV6_PATTERN = Pattern.compile( "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|:((:[0-9a-fA-F]{1,4}){1,7}|:)|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4})$"); - /** - * Private constructor to prevent instantiation - */ - private IpAddressExtractor() { - super(); - } - /** * Extract client IP address with validation and reliability checks - * * @return most reliable client IP address found */ - public static String getClientIpAddress() { - HttpServletRequest request = getCurrentRequest(); + public String getClientIpAddress() { + Optional requestOpt = httpContextService.getCurrentRequestOptional(); + if (requestOpt.isEmpty()) { + log.warn("No HTTP context available for IP extraction"); + return "unknown"; + } + + HttpServletRequest request = requestOpt.get(); return IP_HEADER_CANDIDATES.stream() .map(request::getHeader) .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) .map(ip -> ip.split(",")[0].trim()) // Take first IP from comma-separated list - .filter(IpAddressExtractor::isValidIpAddress) - .filter(IpAddressExtractor::isPublicIpAddress) // Prefer public IPs + .filter(this::isValidIpAddress) + .filter(this::isPublicIpAddress) // Prefer public IPs .findFirst() .orElseGet(() -> { // Fallback: try to get any valid IP (including private) @@ -67,7 +77,7 @@ public static String getClientIpAddress() { .map(request::getHeader) .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) .map(ip -> ip.split(",")[0].trim()) - .filter(IpAddressExtractor::isValidIpAddress) + .filter(this::isValidIpAddress) .findFirst() .orElseGet(request::getRemoteAddr); @@ -77,9 +87,16 @@ public static String getClientIpAddress() { /** * Get client IP with reliability score for monitoring/logging + * @return IP address information with reliability assessment */ - public static IpAddressInfo getClientIpAddressWithInfo() { - HttpServletRequest request = getCurrentRequest(); + public IpAddressInfo getClientIpAddressWithInfo() { + Optional requestOpt = httpContextService.getCurrentRequestOptional(); + if (requestOpt.isEmpty()) { + log.warn("No HTTP context available for IP extraction"); + return new IpAddressInfo("unknown", "NO_CONTEXT", ReliabilityLevel.LOW); + } + + HttpServletRequest request = requestOpt.get(); for (int i = 0; i < IP_HEADER_CANDIDATES.size(); i++) { String headerName = IP_HEADER_CANDIDATES.get(i); @@ -103,8 +120,10 @@ public static IpAddressInfo getClientIpAddressWithInfo() { /** * Validate if string is a valid IP address (IPv4 or IPv6) + * @param ip IP address string to validate + * @return true if valid IP address */ - private static boolean isValidIpAddress(String ip) { + public boolean isValidIpAddress(String ip) { if (ip == null || ip.trim().isEmpty()) { return false; } @@ -119,8 +138,10 @@ private static boolean isValidIpAddress(String ip) { /** * Check if IP address is public (not private/local) + * @param ip IP address to check + * @return true if public IP address */ - private static boolean isPublicIpAddress(String ip) { + public boolean isPublicIpAddress(String ip) { if (!isValidIpAddress(ip)) { return false; } @@ -130,8 +151,10 @@ private static boolean isPublicIpAddress(String ip) { /** * Check if IP is in private ranges + * @param ip IP address to check + * @return true if private IP address */ - private static boolean isPrivateIpAddress(String ip) { + public boolean isPrivateIpAddress(String ip) { // Check IPv6 private ranges first if (ip.contains(":")) { return isPrivateIpv6(ip); @@ -146,7 +169,7 @@ private static boolean isPrivateIpAddress(String ip) { return false; } - private static boolean isPrivateIpv6(String ip) { + private boolean isPrivateIpv6(String ip) { try { InetAddress addr = InetAddress.getByName(ip); return addr.isSiteLocalAddress() @@ -161,7 +184,7 @@ private static boolean isPrivateIpv6(String ip) { /** * Check if 172.x.x.x IP is in private range (172.16.0.0 to 172.31.255.255) */ - private static boolean isPrivate172Range(String ip) { + private boolean isPrivate172Range(String ip) { String[] octets = ip.split("\\."); if (octets.length < 2) { return false; @@ -178,11 +201,11 @@ private static boolean isPrivate172Range(String ip) { /** * Check if IP is localhost or other special addresses */ - private static boolean isSpecialAddress(String ip) { + private boolean isSpecialAddress(String ip) { return ip.equals("127.0.0.1") || ip.equals("::1") || ip.equals("0.0.0.0"); } - private static ReliabilityLevel getReliabilityLevel(String headerName, String ip) { + private ReliabilityLevel getReliabilityLevel(String headerName, String ip) { // Rate headers by trustworthiness return switch (headerName) { case "CF-Connecting-IP", "True-Client-IP" -> ReliabilityLevel.HIGH; @@ -217,6 +240,12 @@ public String getSourceHeader() { public ReliabilityLevel getReliability() { return reliability; } + + @Override + public String toString() { + return String.format("IpAddressInfo{ip='%s', source='%s', reliability=%s}", + ipAddress, sourceHeader, reliability); + } } public enum ReliabilityLevel { @@ -224,4 +253,4 @@ public enum ReliabilityLevel { MEDIUM, // Nginx, proper proxies - generally reliable LOW // Easy to spoof headers - use with caution } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/http/RequestDataExtractor.java b/src/main/java/com/juu/juulabel/common/http/RequestDataExtractor.java new file mode 100644 index 0000000..ebab046 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/http/RequestDataExtractor.java @@ -0,0 +1,143 @@ +package com.juu.juulabel.common.http; + +import java.util.Optional; + +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for extracting various data from HTTP requests. + * Handles header extraction, path matching, and parameter retrieval. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RequestDataExtractor { + + private static final String DEVICE_ID_HEADER_NAME = "Device-Id"; + + private final HttpContextService httpContextService; + + /** + * Checks if the current request path matches the given prefix + * @param pathPrefix Path prefix to match against + * @return true if path matches, false otherwise + */ + public boolean isPathMatch(String pathPrefix) { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getRequestURI) + .map(uri -> uri.startsWith(pathPrefix)) + .orElse(false); + } + + /** + * Extracts Authorization header from current request + * @return Authorization header value, or null if not present + */ + public Optional getAuthorizationHeader() { + return getHeaderValue(HttpHeaders.AUTHORIZATION); + } + + /** + * Extracts User-Agent header from current request + * @return User-Agent header value, or null if not present + */ + public Optional getUserAgent() { + return getHeaderValue(HttpHeaders.USER_AGENT); + } + + /** + * Extracts device ID from request headers with fallback to parameter + * @return Device ID value + * @throws BaseException if Device-Id is missing or empty + */ + public String getDeviceId() { + HttpServletRequest request = httpContextService.getCurrentRequest(); + + // Try header first + String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); + + // Fallback to state parameter + if (!StringUtils.hasText(deviceId)) { + deviceId = request.getParameter("state"); + } + + if (!StringUtils.hasText(deviceId)) { + log.warn("Device-Id not found in headers or parameters for request: {}", + request.getRequestURI()); + throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); + } + + return deviceId.trim(); + } + + /** + * Safely extracts device ID without throwing exception + * @return Optional containing device ID if present + */ + public Optional getDeviceIdOptional() { + try { + return Optional.of(getDeviceId()); + } catch (BaseException e) { + log.debug("Device ID not available: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Gets a specific header value from the current request + * @param headerName Name of the header to retrieve + * @return Optional containing header value if present + */ + public Optional getHeaderValue(String headerName) { + return httpContextService.getCurrentRequestOptional() + .map(request -> request.getHeader(headerName)) + .filter(StringUtils::hasText); + } + + /** + * Gets a specific parameter value from the current request + * @param parameterName Name of the parameter to retrieve + * @return Optional containing parameter value if present + */ + public Optional getParameterValue(String parameterName) { + return httpContextService.getCurrentRequestOptional() + .map(request -> request.getParameter(parameterName)) + .filter(StringUtils::hasText); + } + + /** + * Gets the current request URI + * @return Optional containing request URI if available + */ + public Optional getRequestURI() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getRequestURI); + } + + /** + * Gets the current request method + * @return Optional containing request method if available + */ + public Optional getRequestMethod() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getMethod); + } + + /** + * Gets the remote address from the request + * @return Optional containing remote address if available + */ + public Optional getRemoteAddress() { + return httpContextService.getCurrentRequestOptional() + .map(HttpServletRequest::getRemoteAddr); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java b/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java index ee60bde..e28a95f 100644 --- a/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/oauth/AppleProvider.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; import com.juu.juulabel.common.client.AppleAuthClient; -import com.juu.juulabel.common.provider.token.jwt.AppleTokenProvider; +import com.juu.juulabel.auth.service.AppleTokenService; import com.juu.juulabel.member.request.ApplePublicKey; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.token.OAuthToken; @@ -18,7 +18,7 @@ public class AppleProvider implements OAuthProvider { private final AppleAuthClient appleAuthClient; - private final AppleTokenProvider appleTokenProvider; + private final AppleTokenService appleTokenService; @Value("${spring.security.oauth2.client.registration.apple.authorization-grant-type}") private String grantType; @@ -43,7 +43,7 @@ public OAuthToken getOAuthToken(String redirectUri, String code) { public OAuthUser getOAuthUser(OAuthToken oauthToken) { List publicKeys = appleAuthClient.getApplePublicKeys(); - return appleTokenProvider.getAppleUserFromToken(publicKeys, oauthToken); + return appleTokenService.extractAppleUser(publicKeys, oauthToken); } } diff --git a/src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java deleted file mode 100644 index f141433..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/token/TokenProvider.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.juu.juulabel.common.provider.token; - -import java.time.Duration; -import java.util.function.Function; - -import org.springframework.util.StringUtils; - -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.exception.InvalidParamException; - -public abstract class TokenProvider { - - public static final String ISSUER = "juulabel.com"; - protected final Duration duration; - - protected TokenProvider(Duration duration) { - this.duration = duration; - } - - public String resolveToken(String header) { - if (!StringUtils.hasText(header)) { - throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); - } - return header.replace(AuthConstants.TOKEN_PREFIX, ""); - } - - public abstract F extractFromClaims(String token, Function claimsResolver); - - public abstract T parseClaims(String token); -} diff --git a/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java new file mode 100644 index 0000000..29baf29 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java @@ -0,0 +1,30 @@ +package com.juu.juulabel.common.provider.token; + +import java.util.function.Function; + +/** + * Generic token service interface that separates token operations + * from specific validation and business logic. + */ +public interface TokenService { + + /** + * Creates a token with the provided claims + */ + String createToken(T claims); + + /** + * Parses and validates a token, returning the claims + */ + T parseToken(String token); + + /** + * Extracts specific information from token claims + */ + R extractFromToken(String token, Function extractor); + + /** + * Validates if a token is structurally valid + */ + boolean isValidToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java deleted file mode 100644 index 8adb8b6..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.juu.juulabel.common.provider.token.jwt; - -import com.juu.juulabel.common.exception.CustomJwtException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.provider.token.TokenProvider; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.security.Keys; -import io.jsonwebtoken.security.SignatureException; - -import org.springframework.stereotype.Component; - -import java.util.Base64; -import java.security.Key; -import java.time.Duration; -import java.util.Date; -import java.util.Map; -import java.util.function.Function; - -import javax.crypto.SecretKey; - -@Component -public abstract class JwtTokenProvider extends TokenProvider { - - protected Key key; - protected JwtParser jwtParser; - - protected JwtTokenProvider(String secretKey, Duration duration) { - super(duration); - this.key = secretKey != null ? Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)) : null; - this.jwtParser = this.key != null ? Jwts.parser().verifyWith((SecretKey) this.key).build() : null; - } - - public JwtBuilder build(Map claims) { - return Jwts.builder() - .claims(claims) - .issuer(ISSUER) - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + this.duration.toMillis())) - // .audience(ISSUER) - .signWith(key); - } - - @Override - public T extractFromClaims(String token, Function claimsResolver) { - return claimsResolver.apply(parseClaims(token)); - } - - @Override - public 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/token/jwt/JwtTokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java new file mode 100644 index 0000000..ff0fc40 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java @@ -0,0 +1,102 @@ +package com.juu.juulabel.common.provider.token.jwt; + +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.function.Function; + +import javax.crypto.SecretKey; + +import com.juu.juulabel.common.exception.CustomJwtException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.token.TokenService; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; + +/** + * JWT-specific implementation of TokenService + */ +public class JwtTokenService implements TokenService { + + public static final String DEFAULT_ISSUER = "juulabel.com"; + + private final SecretKey key; + private final JwtParser jwtParser; + private final Duration tokenDuration; + + public JwtTokenService(String secretKey, Duration duration) { + this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey)); + this.jwtParser = Jwts.parser().verifyWith(this.key).build(); + this.tokenDuration = duration; + } + + @Override + public String createToken(Claims claims) { + // For now, we'll focus on the Map-based approach which works better + throw new UnsupportedOperationException("Use createToken(Map) instead"); + } + + /** + * Creates a token with custom claims map + */ + public String createToken(Map claimsMap) { + return Jwts.builder() + .claims(claimsMap) + .issuer(DEFAULT_ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + tokenDuration.toMillis())) + .signWith(key) + .compact(); + } + + /** + * Creates a JWT builder with claims for more advanced token creation + */ + public JwtBuilder createBuilder(Map claims) { + return Jwts.builder() + .claims(claims) + .issuer(DEFAULT_ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + tokenDuration.toMillis())) + .signWith(key); + } + + @Override + public Claims parseToken(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); + } + } + + @Override + public R extractFromToken(String token, Function extractor) { + return extractor.apply(parseToken(token)); + } + + @Override + public boolean isValidToken(String token) { + try { + parseToken(token); + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java similarity index 50% rename from src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java rename to src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java index 4178b48..319411a 100644 --- a/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java @@ -2,56 +2,72 @@ import java.time.Duration; import java.time.Instant; +import java.util.Map; import java.util.function.Function; import javax.crypto.SecretKey; import com.juu.juulabel.common.exception.CustomPasetoException; import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.provider.token.TokenProvider; +import com.juu.juulabel.common.provider.token.TokenService; -import dev.paseto.jpaseto.Pasetos; -import dev.paseto.jpaseto.PasetoParser; import dev.paseto.jpaseto.Claims; -import dev.paseto.jpaseto.PasetoIOException; +import dev.paseto.jpaseto.ExpiredPasetoException; +import dev.paseto.jpaseto.IncorrectClaimException; +import dev.paseto.jpaseto.MissingClaimException; import dev.paseto.jpaseto.PasetoException; +import dev.paseto.jpaseto.PasetoIOException; import dev.paseto.jpaseto.PasetoKeyException; +import dev.paseto.jpaseto.PasetoParser; import dev.paseto.jpaseto.PasetoSignatureException; -import dev.paseto.jpaseto.PasetoV2LocalBuilder; -import dev.paseto.jpaseto.ExpiredPasetoException; -import dev.paseto.jpaseto.MissingClaimException; -import dev.paseto.jpaseto.IncorrectClaimException; +import dev.paseto.jpaseto.Pasetos; import dev.paseto.jpaseto.RequiredTypeException; import dev.paseto.jpaseto.lang.Keys; -public abstract class PasetoTokenProvider extends TokenProvider { +/** + * PASETO-specific implementation of TokenService + */ +public class PasetoTokenService implements TokenService { + + public static final String DEFAULT_ISSUER = "juulabel.com"; + public static final String DEFAULT_AUDIENCE = "juu-label-client"; - protected final PasetoParser parser; - protected final SecretKey key; + private final SecretKey secretKey; + private final PasetoParser parser; + private final Duration tokenDuration; - protected PasetoTokenProvider(String secretKey, Duration duration) { - super(duration); - this.key = Keys.secretKey(secretKey.getBytes()); + public PasetoTokenService(String secretKey, Duration duration) { + this.secretKey = Keys.secretKey(secretKey.getBytes()); this.parser = Pasetos.parserBuilder() - .setSharedSecret(this.key) + .setSharedSecret(this.secretKey) .build(); + this.tokenDuration = duration; } - protected PasetoV2LocalBuilder builder() { - return Pasetos.V2.LOCAL.builder() - .setSharedSecret(this.key) - .setIssuer(ISSUER) - .setAudience("juu-label-client") - .setIssuedAt(Instant.now()) - .setExpiration(Instant.now().plus(this.duration)); + @Override + public String createToken(Claims claims) { + // For now, we'll focus on the Map-based approach which works + throw new UnsupportedOperationException("Use createToken(Map) instead"); } - @Override - public T extractFromClaims(String token, Function claimsResolver) { - return claimsResolver.apply(parseClaims(token)); + /** + * Creates a token with custom claims map + */ + public String createToken(Map claimsMap) { + var builder = Pasetos.V2.LOCAL.builder() + .setSharedSecret(secretKey) + .setIssuer(DEFAULT_ISSUER) + .setIssuedAt(Instant.now()) + .setExpiration(Instant.now().plus(tokenDuration)); + + // Add custom claims + claimsMap.forEach(builder::claim); + + return builder.compact(); } - public Claims parseClaims(String token) { + @Override + public Claims parseToken(String token) { try { return parser.parse(token).getClaims(); } catch (ExpiredPasetoException e) { @@ -66,4 +82,19 @@ public Claims parseClaims(String token) { throw new CustomPasetoException(ErrorCode.PAS_UNSUPPORTED_EXCEPTION); } } -} + + @Override + public R extractFromToken(String token, Function extractor) { + return extractor.apply(parseToken(token)); + } + + @Override + public boolean isValidToken(String token) { + try { + parseToken(token); + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java deleted file mode 100644 index 854f2e5..0000000 --- a/src/main/java/com/juu/juulabel/common/provider/token/paseto/SignupTokenProvider.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.juu.juulabel.common.provider.token.paseto; - -import java.util.Collections; - -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.common.constants.AuthConstants; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.MemberStatus; -import com.juu.juulabel.member.domain.Provider; -import com.juu.juulabel.member.repository.MemberReader; -import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.common.exception.AuthException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.util.CookieUtil; - -import dev.paseto.jpaseto.Claims; - -@Component -public class SignupTokenProvider extends PasetoTokenProvider { - - private static final String AUDIENCE_CLAIM = "user-signup-completion"; - private static final String EMAIL_CLAIM = "email"; - private static final String PROVIDER_CLAIM = "provider"; - private static final String PROVIDER_ID_CLAIM = "providerId"; - private static final String NONCE_CLAIM = "nonce"; - private static final String AUDIENCE_CLAIM_KEY = "aud"; - - private final MemberReader memberReader; - private final CookieUtil cookieUtil; - - public SignupTokenProvider(@Value("${spring.jwt.signup-key}") String secretKey, MemberReader memberReader, - CookieUtil cookieUtil) { - super(secretKey, AuthConstants.SIGN_UP_TOKEN_DURATION); - this.memberReader = memberReader; - this.cookieUtil = cookieUtil; - } - - public void createToken(OAuthUser oAuthUser, String nonce) { - String token = builder() - .claim(EMAIL_CLAIM, oAuthUser.email()) - .claim(PROVIDER_CLAIM, oAuthUser.provider().name()) - .claim(PROVIDER_ID_CLAIM, oAuthUser.id()) - .claim(NONCE_CLAIM, nonce) - .claim(AUDIENCE_CLAIM_KEY, AUDIENCE_CLAIM) - .compact(); - cookieUtil.addCookie(AuthConstants.SIGN_UP_TOKEN_NAME, token, - (int) AuthConstants.SIGN_UP_TOKEN_DURATION.toSeconds()); - } - - public Authentication getAuthentication(String token) { - Member member = verifyToken(token); - return new UsernamePasswordAuthenticationToken(member, null, Collections.emptyList()); - } - - public Member verifyToken(String token) { - Claims claims = parseClaims(token); - - // Extract and validate all claims at once - TokenClaims tokenClaims = extractTokenClaims(claims); - - // Validate audience first (fast check) - if (!AUDIENCE_CLAIM.equals(tokenClaims.audience())) { - throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); - } - - // Get member and validate - Member member = memberReader.getByEmail(tokenClaims.email()); - validateMemberAgainstToken(member, tokenClaims); - - return member; - } - - private TokenClaims extractTokenClaims(Claims claims) { - try { - return new TokenClaims( - getRequiredClaimAsString(claims, EMAIL_CLAIM), - Provider.valueOf(getRequiredClaimAsString(claims, PROVIDER_CLAIM)), - getRequiredClaimAsString(claims, PROVIDER_ID_CLAIM), - getRequiredClaimAsString(claims, NONCE_CLAIM), - getRequiredClaimAsString(claims, AUDIENCE_CLAIM_KEY)); - } catch (IllegalArgumentException e) { - throw new AuthException("Invalid provider in token", ErrorCode.INVALID_AUTHENTICATION); - } - } - - private void validateMemberAgainstToken(Member member, TokenClaims tokenClaims) { - // Check provider and provider ID - if (member.getProvider() != tokenClaims.provider()) { - throw new AuthException("Provider mismatch", ErrorCode.PROVIDER_ID_MISMATCH); - } - - if (!member.getProviderId().equals(tokenClaims.providerId())) { - throw new AuthException("Provider ID mismatch", ErrorCode.PROVIDER_ID_MISMATCH); - } - - if (!member.getNickname().equals(tokenClaims.nonce())) { - throw new AuthException("Token validation failed", ErrorCode.INVALID_AUTHENTICATION); - } - - // Check member status - if (member.getStatus() != MemberStatus.PENDING) { - throw new AuthException("Member already completed signup", ErrorCode.INVALID_AUTHENTICATION); - } - } - - private String getRequiredClaimAsString(Claims claims, String claimName) { - Object claimValue = claims.get(claimName); - if (claimValue == null) { - throw new AuthException("Missing required claim: " + claimName, ErrorCode.INVALID_AUTHENTICATION); - } - return claimValue.toString(); - } - - /** - * Record to hold extracted token claims for better type safety and performance - */ - private record TokenClaims( - String email, - Provider provider, - String providerId, - String nonce, - String audience) { - } -} diff --git a/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenClaims.java b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenClaims.java new file mode 100644 index 0000000..2049546 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenClaims.java @@ -0,0 +1,34 @@ +package com.juu.juulabel.common.provider.token.validator; + +import java.util.Map; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Provider; + +/** + * Record to hold signup token claims + */ +public record SignupTokenClaims( + String email, + Provider provider, + String providerId, + String nonce, + String audience) { + public static SignupTokenClaims from(Map claims) { + return new SignupTokenClaims( + getRequiredClaim(claims, "email"), + Provider.valueOf(getRequiredClaim(claims, "provider")), + getRequiredClaim(claims, "providerId"), + getRequiredClaim(claims, "nonce"), + getRequiredClaim(claims, "aud")); + } + + private static String getRequiredClaim(Map claims, String claimName) { + Object value = claims.get(claimName); + if (value == null) { + throw new AuthException("Missing required claim: " + claimName, ErrorCode.INVALID_AUTHENTICATION); + } + return value.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenValidator.java b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenValidator.java new file mode 100644 index 0000000..d2bd264 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/validator/SignupTokenValidator.java @@ -0,0 +1,73 @@ +package com.juu.juulabel.common.provider.token.validator; + +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberStatus; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.repository.MemberReader; + +import lombok.RequiredArgsConstructor; + +/** + * Validator for signup token claims and associated member data + */ +@Component +@RequiredArgsConstructor +public class SignupTokenValidator implements TokenValidator { + + private static final String EXPECTED_AUDIENCE = "user-signup-completion"; + + private final MemberReader memberReader; + + @Override + public void validate(SignupTokenClaims claims) { + validateAudience(claims.audience()); + Member member = memberReader.getByEmail(claims.email()); + validateMemberAgainstClaims(member, claims); + } + + @Override + public String getValidationType() { + return "SIGNUP_TOKEN"; + } + + private void validateAudience(String audience) { + if (!EXPECTED_AUDIENCE.equals(audience)) { + throw new AuthException("Invalid token audience", ErrorCode.INVALID_AUTHENTICATION); + } + } + + private void validateMemberAgainstClaims(Member member, SignupTokenClaims claims) { + validateProvider(member, claims); + validateProviderId(member, claims); + validateNonce(member, claims); + validateMemberStatus(member); + } + + private void validateProvider(Member member, SignupTokenClaims claims) { + if (member.getProvider() != claims.provider()) { + throw new AuthException("Provider mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + } + + private void validateProviderId(Member member, SignupTokenClaims claims) { + if (!member.getProviderId().equals(claims.providerId())) { + throw new AuthException("Provider ID mismatch", ErrorCode.PROVIDER_ID_MISMATCH); + } + } + + private void validateNonce(Member member, SignupTokenClaims claims) { + if (!member.getNickname().equals(claims.nonce())) { + throw new AuthException("Token validation failed", ErrorCode.INVALID_AUTHENTICATION); + } + } + + private void validateMemberStatus(Member member) { + if (member.getStatus() != MemberStatus.PENDING) { + throw new AuthException("Member already completed signup", ErrorCode.INVALID_AUTHENTICATION); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/token/validator/TokenValidator.java b/src/main/java/com/juu/juulabel/common/provider/token/validator/TokenValidator.java new file mode 100644 index 0000000..ed2d5a2 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/provider/token/validator/TokenValidator.java @@ -0,0 +1,20 @@ +package com.juu.juulabel.common.provider.token.validator; + +/** + * Interface for validating token claims against business rules + */ +public interface TokenValidator { + + /** + * Validates the token claims + * + * @param claims the parsed token claims + * @throws RuntimeException if validation fails + */ + void validate(T claims); + + /** + * Returns the validation type for logging/debugging + */ + String getValidationType(); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/session/SessionAuthenticationProvider.java b/src/main/java/com/juu/juulabel/common/session/SessionAuthenticationProvider.java new file mode 100644 index 0000000..5e585b6 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/session/SessionAuthenticationProvider.java @@ -0,0 +1,36 @@ +package com.juu.juulabel.common.session; + +import java.util.Collections; + +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 com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.token.UserSession; + +/** + * Service for creating Spring Security Authentication objects from sessions + */ +@Component +public class SessionAuthenticationProvider { + + /** + * Creates Spring Security Authentication from UserSession + * @param session The user session + * @return Authentication object + */ + public Authentication createAuthentication(UserSession session) { + Member member = Member.builder() + .id(session.getMemberId()) + .role(session.getRole()) + .email(session.getEmail()) + .build(); + + return new UsernamePasswordAuthenticationToken( + member, + null, + Collections.singletonList(new SimpleGrantedAuthority(session.getRole().name()))); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/session/SessionService.java b/src/main/java/com/juu/juulabel/common/session/SessionService.java new file mode 100644 index 0000000..8f100c5 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/session/SessionService.java @@ -0,0 +1,42 @@ +package com.juu.juulabel.common.session; + +import java.util.Optional; + +/** + * Generic session management interface - technology agnostic + * + * @param Session entity type + * @param Session identifier type + */ +public interface SessionService { + + /** + * Creates a new session + */ + T createSession(T session); + + /** + * Retrieves a session by ID + */ + Optional getSession(ID sessionId); + + /** + * Updates an existing session + */ + T updateSession(T session); + + /** + * Deletes a session by ID + */ + void deleteSession(ID sessionId); + + /** + * Deletes all sessions for a specific user + */ + void deleteAllUserSessions(Long userId); + + /** + * Checks if a session exists + */ + boolean sessionExists(ID sessionId); +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/session/SessionTokenGenerator.java b/src/main/java/com/juu/juulabel/common/session/SessionTokenGenerator.java new file mode 100644 index 0000000..932afcd --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/session/SessionTokenGenerator.java @@ -0,0 +1,53 @@ +package com.juu.juulabel.common.session; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.function.Predicate; + +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import lombok.extern.slf4j.Slf4j; + +/** + * Service for generating cryptographically secure session tokens + */ +@Slf4j +@Component +public class SessionTokenGenerator { + + private static final int TOKEN_LENGTH = 32; + private static final int MAX_RETRY_ATTEMPTS = 3; + + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * Generates a unique session token with collision detection + * @param existenceChecker Function to check if a token already exists + * @return Unique session token + */ + public String generateUniqueToken(Predicate existenceChecker) { + for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + String token = generateSecureToken(); + + if (!existenceChecker.test(token)) { + return token; + } + + log.warn("Session token collision detected, retrying... Attempt: {}", attempt + 1); + } + + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + /** + * Generates a simple secure token without collision detection + */ + public String generateSecureToken() { + byte[] tokenBytes = new byte[TOKEN_LENGTH]; + secureRandom.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java b/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java deleted file mode 100644 index 1db32c8..0000000 --- a/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.juu.juulabel.common.util; - -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import java.util.Optional; -import java.util.function.Function; - -/** - * Abstract base class for HTTP utility operations - */ -public abstract class AbstractHttpUtil { - - protected AbstractHttpUtil() { - } - - /** - * Gets the current HTTP request from the servlet context - * - * @return the current HttpServletRequest - * @throws BaseException if request attributes are not available - */ - protected static HttpServletRequest getCurrentRequest() { - return getFromRequestAttributes(ServletRequestAttributes::getRequest); - } - - /** - * Gets the current HTTP response from the servlet context - * - * @return the current HttpServletResponse - * @throws BaseException if request attributes are not available - */ - protected static HttpServletResponse getCurrentResponse() { - return getFromRequestAttributes(ServletRequestAttributes::getResponse); - } - - /** - * Extracts data from ServletRequestAttributes using the provided function - * - * @param extractor function to extract data from ServletRequestAttributes - * @return extracted data - * @throws BaseException if request attributes are not available - */ - protected static T getFromRequestAttributes(Function extractor) { - return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) - .filter(ServletRequestAttributes.class::isInstance) - .map(ServletRequestAttributes.class::cast) - .map(extractor) - .orElseThrow(() -> new BaseException(ErrorCode.INTERNAL_SERVER_ERROR)); - } -} \ 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 deleted file mode 100644 index b8d5997..0000000 --- a/src/main/java/com/juu/juulabel/common/util/CookieUtil.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.juu.juulabel.common.util; - -import com.juu.juulabel.common.properties.CookieProperties; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.util.Arrays; -import java.util.Optional; - -import org.springframework.stereotype.Component; - -/** - * Utility class for secure cookie management operations. - * Provides methods for creating, retrieving, and removing HTTP cookies with - * security best practices. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public final class CookieUtil extends AbstractHttpUtil { - - // Cookie removal configuration - private static final int COOKIE_REMOVAL_MAX_AGE = 0; - private static final String EMPTY_VALUE = ""; - - private final CookieProperties cookieProperties; - - /** - * Retrieves a cookie value by name from the current HTTP request. - * - * @param name the cookie name to search for - * @return the cookie value if found, null otherwise - */ - public String getCookie(String name) { - HttpServletRequest request = getCurrentRequest(); - Cookie[] cookies = request.getCookies(); - - if (cookies == null) { - log.debug("No cookies found in request"); - return null; - } - - return Arrays.stream(cookies) - .filter(cookie -> name.equals(cookie.getName())) - .map(Cookie::getValue) - .findFirst() - .orElse(null); - } - - /** - * Retrieves a cookie as an Optional to avoid null pointer exceptions. - * - * @param name the cookie name to search for - * @return Optional containing the cookie value if found, empty otherwise - */ - public Optional getCookieOptional(String name) { - return Optional.ofNullable(getCookie(name)); - } - - /** - * Adds a secure HTTP-only cookie to the response with comprehensive security - * settings. - * - * @param name the cookie name - * @param value the cookie value - * @param maxAge the cookie max age in seconds - */ - public void addCookie(String name, String value, int maxAge) { - HttpServletResponse response = getCurrentResponse(); - Cookie cookie = createSecureCookie(name, value, maxAge); - response.addCookie(cookie); - } - - /** - * Adds a cookie with default security settings from configuration. - * - * @param name the cookie name - * @param value the cookie value - * @param maxAge the cookie max age in seconds - */ - public void addSecureCookie(String name, String value, int maxAge) { - addCookie(name, value, maxAge); - } - - /** - * Removes a cookie by setting its max age to 0 and clearing its value. - * This method ensures proper cookie removal across different browsers. - * - * @param name the cookie name to remove - */ - public void removeCookie(String name) { - HttpServletResponse response = getCurrentResponse(); - - // Create removal cookie with both secure and non-secure variants - // to ensure removal regardless of original cookie settings - Cookie removeCookie = createRemovalCookie(name, false); - response.addCookie(removeCookie); - - // Also add secure variant for removal - Cookie secureRemoveCookie = createRemovalCookie(name, true); - response.addCookie(secureRemoveCookie); - } - - /** - * Checks if a cookie with the given name exists in the current request. - * - * @param name the cookie name to check - * @return true if cookie exists, false otherwise - */ - public boolean cookieExists(String name) { - return getCookie(name) != null; - } - - /** - * Creates a secure cookie with comprehensive security settings. - */ - private Cookie createSecureCookie(String name, String value, int maxAge) { - boolean isSecure = cookieProperties.isSecure(); - Cookie cookie = new Cookie(name, value); - - // Set domain only for production/secure environments - if (isSecure) { - cookie.setDomain(cookieProperties.getDomain()); - } - - cookie.setPath(cookieProperties.getPath()); - cookie.setHttpOnly(cookieProperties.isHttpOnly()); - cookie.setSecure(isSecure); - cookie.setMaxAge(maxAge); - - // Set SameSite attribute based on security requirements - String sameSite = isSecure ? cookieProperties.getSameSiteSecure() : cookieProperties.getSameSiteNonSecure(); - cookie.setAttribute("SameSite", sameSite); - - return cookie; - } - - /** - * Creates a cookie specifically for removal purposes. - */ - private Cookie createRemovalCookie(String name, boolean isSecure) { - Cookie cookie = new Cookie(name, EMPTY_VALUE); - - if (isSecure) { - cookie.setDomain(cookieProperties.getDomain()); - cookie.setSecure(true); - } - - cookie.setPath(cookieProperties.getPath()); - cookie.setHttpOnly(cookieProperties.isHttpOnly()); - cookie.setMaxAge(COOKIE_REMOVAL_MAX_AGE); - - return cookie; - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java deleted file mode 100644 index c214b43..0000000 --- a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java +++ /dev/null @@ -1,57 +0,0 @@ -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; - -public class HttpRequestUtil extends AbstractHttpUtil { - - private static final String DEVICE_ID_HEADER_NAME = "Device-Id"; - - 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 - * - * @return device ID from Device-Id header - * @throws BaseException if Device-Id header is missing or empty - */ - public static String getDeviceId() { - HttpServletRequest request = getCurrentRequest(); - String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); - if (deviceId == null || deviceId.trim().isEmpty()) { - deviceId = request.getParameter("state"); - } - - if (deviceId == null || deviceId.trim().isEmpty()) { - throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); - } - return deviceId.trim(); - } - - /** - * 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 c64509c..0000000 --- a/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.juu.juulabel.common.util; - -import java.io.IOException; - -import org.springframework.stereotype.Component; - -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.properties.RedirectProperties; - -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class HttpResponseUtil extends AbstractHttpUtil { - - private final RedirectProperties redirectProperties; - - public void redirectToLogin() { - redirect(redirectProperties.getLoginUrl()); - } - - public void redirectToSignup() { - redirect(redirectProperties.getSignupUrl()); - } - - public void redirectToError() { - redirect(redirectProperties.getErrorUrl()); - } - - private void redirect(String url) { - try { - HttpServletResponse response = getCurrentResponse(); - response.sendRedirect(url); - } catch (IOException e) { - throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); - } - } - -} diff --git a/src/main/java/com/juu/juulabel/member/token/UserSession.java b/src/main/java/com/juu/juulabel/member/token/UserSession.java index 03494fb..7e23a65 100644 --- a/src/main/java/com/juu/juulabel/member/token/UserSession.java +++ b/src/main/java/com/juu/juulabel/member/token/UserSession.java @@ -11,14 +11,13 @@ import org.springframework.data.redis.core.index.Indexed; import com.juu.juulabel.common.constants.AuthConstants; -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.member.domain.MemberRole; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder // Remove timeToLive from @RedisHash since we'll use @TimeToLive field @RedisHash(value = "user_session") public class UserSession implements Serializable { @@ -46,23 +45,44 @@ public class UserSession implements Serializable { @TimeToLive private Long ttl; - @Builder - public UserSession(String id, Member member) { + /** + * Factory method to create a UserSession from Member data + * This replaces the business logic that was in the constructor + */ + public static UserSession createFromMember(String sessionId, Member member, + String deviceId, String ipAddress, String userAgent) { final LocalDateTime now = LocalDateTime.now(); - this.id = id; - this.memberId = member.getId(); - this.email = member.getEmail(); - this.role = member.getRole(); - this.deviceId = HttpRequestUtil.getDeviceId(); - this.ipAddress = IpAddressExtractor.getClientIpAddress(); - this.userAgent = HttpRequestUtil.getUserAgent(); - this.createdAt = now; - this.lastAccessedAt = now; - this.ttl = (long) AuthConstants.USER_SESSION_TTL; // 7 days in seconds + + return UserSession.builder() + .id(sessionId) + .memberId(member.getId()) + .email(member.getEmail()) + .role(member.getRole()) + .deviceId(deviceId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .createdAt(now) + .lastAccessedAt(now) + .ttl((long) AuthConstants.USER_SESSION_TTL) + .build(); } - public void updateLastAccessed() { - this.lastAccessedAt = LocalDateTime.now(); - this.ttl = (long) AuthConstants.USER_SESSION_TTL; // Reset TTL to original value + /** + * Creates a copy of this session with updated last accessed time + * This replaces the business logic that was in updateLastAccessed method + */ + public UserSession withUpdatedLastAccessed() { + return UserSession.builder() + .id(this.id) + .memberId(this.memberId) + .email(this.email) + .role(this.role) + .deviceId(this.deviceId) + .ipAddress(this.ipAddress) + .userAgent(this.userAgent) + .createdAt(this.createdAt) + .lastAccessedAt(LocalDateTime.now()) + .ttl((long) AuthConstants.USER_SESSION_TTL) // Reset TTL + .build(); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/redis/RedisSessionService.java b/src/main/java/com/juu/juulabel/redis/RedisSessionService.java new file mode 100644 index 0000000..c014614 --- /dev/null +++ b/src/main/java/com/juu/juulabel/redis/RedisSessionService.java @@ -0,0 +1,71 @@ +package com.juu.juulabel.redis; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.juu.juulabel.auth.repository.UserSessionRepository; +import com.juu.juulabel.common.exception.AuthException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.session.SessionService; +import com.juu.juulabel.member.token.UserSession; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Redis-specific implementation of SessionService + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisSessionService implements SessionService { + + private final UserSessionRepository userSessionRepository; + + @Override + public UserSession createSession(UserSession session) { + try { + return userSessionRepository.save(session); + } catch (Exception e) { + log.error("Failed to create session for member: {}", session.getEmail(), e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public Optional getSession(String sessionId) { + return userSessionRepository.findById(sessionId); + } + + @Override + public UserSession updateSession(UserSession session) { + try { + return userSessionRepository.save(session); + } catch (Exception e) { + log.warn("Failed to update session: {}", session.getId(), e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void deleteSession(String sessionId) { + userSessionRepository.deleteById(sessionId); + } + + @Override + public void deleteAllUserSessions(Long userId) { + try { + userSessionRepository.deleteAllByMemberId(userId); + log.debug("All sessions deleted for user: {}", userId); + } catch (Exception e) { + log.error("Failed to delete all sessions for user: {}", userId, e); + throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public boolean sessionExists(String sessionId) { + return userSessionRepository.existsById(sessionId); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/redis/SessionManager.java b/src/main/java/com/juu/juulabel/redis/SessionManager.java deleted file mode 100644 index 5116ae8..0000000 --- a/src/main/java/com/juu/juulabel/redis/SessionManager.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.juu.juulabel.redis; - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Service; - -import com.juu.juulabel.auth.repository.UserSessionRepository; -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.util.CookieUtil; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.token.UserSession; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Collections; -import java.util.Optional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class SessionManager { - - private static final int TOKEN_LENGTH = 32; - private static final int MAX_RETRY_ATTEMPTS = 3; - - private final SecureRandom secureRandom = new SecureRandom(); - private final UserSessionRepository userSessionRepository; - private final CookieUtil cookieUtil; - - /** - * Creates authentication from current session - */ - public Authentication getAuthentication(String authToken) { - - UserSession session = getSession(authToken); - - Member member = Member.builder() - .id(session.getMemberId()) - .role(session.getRole()) - .email(session.getEmail()) - .build(); - - return new UsernamePasswordAuthenticationToken( - member, - null, - Collections.singletonList(new SimpleGrantedAuthority(session.getRole().name()))); - } - - /** - * Creates new session for member with collision detection - */ - public void createSession(Member member) { - - String sessionId = generateUniqueSessionId(); - UserSession session = new UserSession(sessionId, member); - - try { - userSessionRepository.save(session); - cookieUtil.addCookie(AuthConstants.AUTH_TOKEN_NAME, sessionId, AuthConstants.USER_SESSION_TTL); - - log.debug("Session created successfully for member: {}", member.getEmail()); - } catch (Exception e) { - log.error("Failed to create session for member: {}", member.getEmail(), e); - throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); - } - } - - /** - * Retrieves and validates current session - */ - public UserSession getSession(String authToken) { - - Optional sessionOpt = userSessionRepository.findById(authToken); - if (sessionOpt.isEmpty()) { - log.warn("Session not found for token: {}", maskToken(authToken)); - throw new AuthException(ErrorCode.USER_SESSION_EXPIRED); - } - - UserSession session = sessionOpt.get(); - updateSessionActivity(session); - - return session; - } - - /** - * Invalidates current user session - */ - public void invalidateSession() { - String authToken = cookieUtil.getCookie(AuthConstants.AUTH_TOKEN_NAME); - - userSessionRepository.deleteById(authToken); - cookieUtil.removeCookie(AuthConstants.AUTH_TOKEN_NAME); - } - - /** - * Invalidates all sessions for a user - */ - public void invalidateAllUserSessions(Long userId) { - - try { - userSessionRepository.deleteAllByMemberId(userId); - cookieUtil.removeCookie(AuthConstants.AUTH_TOKEN_NAME); - - log.debug("All sessions invalidated for user: {}", userId); - } catch (Exception e) { - log.error("Failed to invalidate all sessions for user: {}", userId, e); - throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); - } - } - - // Private helper methods - - /** - * Generates unique session ID with collision detection - */ - private String generateUniqueSessionId() { - for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { - String sessionId = generateSecureToken(); - - if (!userSessionRepository.existsById(sessionId)) { - return sessionId; - } - - log.warn("Session ID collision detected, retrying... Attempt: {}", attempt + 1); - } - - throw new AuthException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - /** - * Generates cryptographically secure random token - */ - private String generateSecureToken() { - byte[] tokenBytes = new byte[TOKEN_LENGTH]; - secureRandom.nextBytes(tokenBytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); - } - - /** - * Updates session activity timestamp - */ - private void updateSessionActivity(UserSession session) { - try { - session.updateLastAccessed(); - userSessionRepository.save(session); - } catch (Exception e) { - log.warn("Failed to update session activity for session: {}", session.getId(), e); - // Non-critical operation, don't throw exception - } - } - - /** - * Masks sensitive token for logging - */ - private String maskToken(String token) { - if (token == null || token.length() < 8) { - return "***"; - } - return token.substring(0, 4) + "***" + token.substring(token.length() - 4); - } -} diff --git a/src/main/java/com/juu/juulabel/redis/UserSessionManager.java b/src/main/java/com/juu/juulabel/redis/UserSessionManager.java new file mode 100644 index 0000000..0c1d527 --- /dev/null +++ b/src/main/java/com/juu/juulabel/redis/UserSessionManager.java @@ -0,0 +1,115 @@ +package com.juu.juulabel.redis; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +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.http.CookieService; +import com.juu.juulabel.common.http.IpAddressService; +import com.juu.juulabel.common.http.RequestDataExtractor; +import com.juu.juulabel.common.session.SessionAuthenticationProvider; +import com.juu.juulabel.common.session.SessionService; +import com.juu.juulabel.common.session.SessionTokenGenerator; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.token.UserSession; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Orchestrates user session workflows using separated services + * Replaces the old SessionManager with better separation of concerns + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserSessionManager { + + private final SessionService sessionService; + private final SessionTokenGenerator tokenGenerator; + private final SessionAuthenticationProvider authenticationProvider; + private final CookieService cookieService; + private final RequestDataExtractor requestDataExtractor; + private final IpAddressService ipAddressService; + + /** + * Creates authentication from current session token + */ + public Authentication getAuthentication(String authToken) { + UserSession session = getValidatedSession(authToken); + return authenticationProvider.createAuthentication(session); + } + + /** + * Creates new session for member + */ + public void createSession(Member member) { + // Generate unique session ID + String sessionId = tokenGenerator.generateUniqueToken(sessionService::sessionExists); + + // Create session with current request context + UserSession session = UserSession.createFromMember( + sessionId, + member, + requestDataExtractor.getDeviceId(), + ipAddressService.getClientIpAddress(), + requestDataExtractor.getUserAgent().orElse("unknown")); + + // Save session and set cookie + sessionService.createSession(session); + cookieService.addCookie(AuthConstants.AUTH_TOKEN_NAME, sessionId, AuthConstants.USER_SESSION_TTL); + + log.debug("Session created successfully for member: {}", member.getEmail()); + } + + /** + * Retrieves and validates current session, updating activity + */ + public UserSession getValidatedSession(String authToken) { + UserSession session = sessionService.getSession(authToken) + .orElseThrow(() -> { + log.warn("Session not found for token: {}", maskToken(authToken)); + return new AuthException(ErrorCode.USER_SESSION_EXPIRED); + }); + + // Update session activity (immutable approach) + UserSession updatedSession = session.withUpdatedLastAccessed(); + sessionService.updateSession(updatedSession); + + return updatedSession; + } + + /** + * Invalidates current user session + */ + public void invalidateSession() { + String authToken = cookieService.getCookie(AuthConstants.AUTH_TOKEN_NAME) + .orElse(null); + + if (authToken != null) { + sessionService.deleteSession(authToken); + } + cookieService.removeCookie(AuthConstants.AUTH_TOKEN_NAME); + } + + /** + * Invalidates all sessions for a user + */ + public void invalidateAllUserSessions(Long userId) { + sessionService.deleteAllUserSessions(userId); + cookieService.removeCookie(AuthConstants.AUTH_TOKEN_NAME); + log.debug("All sessions invalidated for user: {}", userId); + } + + /** + * Masks sensitive token for logging + */ + private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); + } +} \ No newline at end of file From d4b2559bedc2c725c91011f33658ee1f210b18f2 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Tue, 3 Jun 2025 22:37:32 +0900 Subject: [PATCH 5/7] Refactor authentication services and improve token handling - Cleaned up whitespace in AuthService methods for better readability. - Updated SignupTokenService to use StringUtils directly and improved exception handling. - Refactored AuthenticationStrategyResolver to initialize strategies in the constructor, enhancing clarity. - Modified TokenService and its implementations to accept a Map for claims, streamlining token creation. - Enhanced PasetoTokenService to utilize PasetoV2LocalBuilder for token generation. These changes aim to improve code clarity, maintainability, and the overall structure of the authentication system. --- .../com/juu/juulabel/auth/service/AuthService.java | 10 +++++----- .../juulabel/auth/service/SignupTokenService.java | 12 +++++++----- .../common/auth/AuthenticationStrategyResolver.java | 9 ++++++--- .../juulabel/common/provider/token/TokenService.java | 3 ++- .../common/provider/token/jwt/JwtTokenService.java | 7 ++----- .../provider/token/paseto/PasetoTokenService.java | 10 +++------- 6 files changed, 25 insertions(+), 26 deletions(-) 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 1d5121e..c5bf8af 100644 --- a/src/main/java/com/juu/juulabel/auth/service/AuthService.java +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -73,7 +73,7 @@ public void login(Provider provider, String code, String state) { public void signUp(Member member, SignUpMemberRequest signUpRequest) { Member completedMember = memberCreationService.completeSignup(member, signUpRequest); sessionManager.createSession(completedMember); - + log.debug("Signup completed successfully for: {}", completedMember.getEmail()); } @@ -101,7 +101,7 @@ private void handleNewMember(OAuthUser oAuthUser) { String nonce = memberCreationService.createPendingMember(oAuthUser); signupTokenService.createToken(oAuthUser, nonce); httpResponseService.redirectToSignup(); - + log.debug("New member flow initiated for: {}", oAuthUser.email()); } @@ -109,14 +109,14 @@ private void handlePendingMember(Member member, OAuthUser oAuthUser) { String nonce = memberCreationService.getExistingNonce(member); signupTokenService.createToken(oAuthUser, nonce); httpResponseService.redirectToSignup(); - + log.debug("Pending member flow initiated for: {}", oAuthUser.email()); } private void handleExistingMember(Member member) { sessionManager.createSession(member); httpResponseService.redirectToLogin(); - + log.debug("Existing member login successful for: {}", member.getEmail()); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java b/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java index 93a878e..48d0fdd 100644 --- a/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java +++ b/src/main/java/com/juu/juulabel/auth/service/SignupTokenService.java @@ -8,8 +8,11 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.http.CookieService; import com.juu.juulabel.common.provider.token.paseto.PasetoTokenService; import com.juu.juulabel.common.provider.token.validator.SignupTokenClaims; @@ -99,9 +102,8 @@ public Member verifyTokenAndGetMember(String token) { * This method maintains compatibility with the existing TokenProvider interface */ public String resolveToken(String header) { - if (!org.springframework.util.StringUtils.hasText(header)) { - throw new com.juu.juulabel.common.exception.InvalidParamException( - com.juu.juulabel.common.exception.code.ErrorCode.INVALID_AUTHENTICATION); + if (!StringUtils.hasText(header)) { + throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); } return header.replace(AuthConstants.TOKEN_PREFIX, ""); } @@ -120,7 +122,7 @@ public void createToken(OAuthUser oAuthUser, String nonce) { */ private Map convertClaimsToMap(Claims claims) { Map claimsMap = new HashMap<>(); - + // Extract standard PASETO claims if (claims.getSubject() != null) { claimsMap.put("sub", claims.getSubject()); @@ -146,7 +148,7 @@ private Map convertClaimsToMap(Claims claims) { // Extract custom claims claims.forEach(claimsMap::put); - + return claimsMap; } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java index 076d666..f3666b0 100644 --- a/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java +++ b/src/main/java/com/juu/juulabel/common/auth/AuthenticationStrategyResolver.java @@ -8,7 +8,6 @@ import org.springframework.stereotype.Component; import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -18,11 +17,16 @@ */ @Slf4j @Component -@RequiredArgsConstructor public class AuthenticationStrategyResolver { private final List strategies; + public AuthenticationStrategyResolver(List strategies) { + this.strategies = strategies.stream() + .sorted(Comparator.comparingInt(AuthenticationStrategy::getOrder)) + .toList(); + } + /** * Resolves authentication for the given request using available strategies * @@ -38,7 +42,6 @@ public Optional resolveAuthentication(HttpServletRequest request // Find the first strategy that can handle this request (sorted by priority) Optional applicableStrategy = strategies.stream() - .sorted(Comparator.comparingInt(AuthenticationStrategy::getOrder)) .filter(strategy -> strategy.canHandle(request)) .findFirst(); diff --git a/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java index 29baf29..ca9d6a5 100644 --- a/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java +++ b/src/main/java/com/juu/juulabel/common/provider/token/TokenService.java @@ -1,5 +1,6 @@ package com.juu.juulabel.common.provider.token; +import java.util.Map; import java.util.function.Function; /** @@ -11,7 +12,7 @@ public interface TokenService { /** * Creates a token with the provided claims */ - String createToken(T claims); + String createToken(Map claimsMap); /** * Parses and validates a token, returning the claims diff --git a/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java index ff0fc40..50e8708 100644 --- a/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java +++ b/src/main/java/com/juu/juulabel/common/provider/token/jwt/JwtTokenService.java @@ -39,15 +39,12 @@ public JwtTokenService(String secretKey, Duration duration) { this.tokenDuration = duration; } - @Override - public String createToken(Claims claims) { - // For now, we'll focus on the Map-based approach which works better - throw new UnsupportedOperationException("Use createToken(Map) instead"); - } + /** * Creates a token with custom claims map */ + @Override public String createToken(Map claimsMap) { return Jwts.builder() .claims(claimsMap) diff --git a/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java index 319411a..e19f4df 100644 --- a/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java +++ b/src/main/java/com/juu/juulabel/common/provider/token/paseto/PasetoTokenService.java @@ -20,6 +20,7 @@ import dev.paseto.jpaseto.PasetoKeyException; import dev.paseto.jpaseto.PasetoParser; import dev.paseto.jpaseto.PasetoSignatureException; +import dev.paseto.jpaseto.PasetoV2LocalBuilder; import dev.paseto.jpaseto.Pasetos; import dev.paseto.jpaseto.RequiredTypeException; import dev.paseto.jpaseto.lang.Keys; @@ -44,17 +45,12 @@ public PasetoTokenService(String secretKey, Duration duration) { this.tokenDuration = duration; } - @Override - public String createToken(Claims claims) { - // For now, we'll focus on the Map-based approach which works - throw new UnsupportedOperationException("Use createToken(Map) instead"); - } - /** * Creates a token with custom claims map */ + @Override public String createToken(Map claimsMap) { - var builder = Pasetos.V2.LOCAL.builder() + PasetoV2LocalBuilder builder = Pasetos.V2.LOCAL.builder() .setSharedSecret(secretKey) .setIssuer(DEFAULT_ISSUER) .setIssuedAt(Instant.now()) From 5473ffb380a8d3ff303fbeab7a47dfcd1d90b8f5 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Tue, 3 Jun 2025 22:43:36 +0900 Subject: [PATCH 6/7] Enhance MemberCreationService and Member class with code formatting improvements - Added whitespace for better readability in MemberCreationService methods. - Updated Javadoc comments for clarity and consistency. - Set default role for new members in the completeSignUp method of the Member class. These changes aim to improve code clarity and maintainability in the member management process. --- .../auth/service/MemberCreationService.java | 17 ++++++++++------- .../com/juu/juulabel/member/domain/Member.java | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java b/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java index 5c099a0..becd8f5 100644 --- a/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java +++ b/src/main/java/com/juu/juulabel/auth/service/MemberCreationService.java @@ -31,6 +31,7 @@ public class MemberCreationService { /** * Creates a new pending member from OAuth user data + * * @param oAuthUser OAuth user information * @return Generated nonce for the new member */ @@ -43,15 +44,16 @@ public String createPendingMember(OAuthUser oAuthUser) { Member newMember = Member.create(oAuthUser, nonce); memberWriter.store(newMember); - log.debug("Created new pending member for email: {} with nonce: {}", - oAuthUser.email(), nonce); - + log.debug("Created new pending member for email: {} with nonce: {}", + oAuthUser.email(), nonce); + return nonce; } /** * Completes member signup with additional information - * @param member Pre-authenticated member from signup token + * + * @param member Pre-authenticated member from signup token * @param signUpRequest Additional member registration details * @return The completed member */ @@ -70,12 +72,13 @@ public Member completeSignup(Member member, SignUpMemberRequest signUpRequest) { memberUtils.processMemberData(member, signUpRequest); log.debug("Completed signup for member: {}", member.getEmail()); - + return member; } /** * Gets existing nonce for pending member (used for existing pending members) + * * @param member Existing pending member * @return The member's existing nonce */ @@ -83,7 +86,7 @@ public String getExistingNonce(Member member) { if (member.getStatus() != MemberStatus.PENDING) { throw new AuthException("Member is not in pending status", ErrorCode.INVALID_AUTHENTICATION); } - + return member.getNickname(); // The nonce is stored in nickname for pending members } -} \ No newline at end of file +} \ 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 cb020fd..41fc8ea 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Member.java +++ b/src/main/java/com/juu/juulabel/member/domain/Member.java @@ -6,7 +6,6 @@ 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.member.token.SignUpToken; import com.juu.juulabel.common.base.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; @@ -80,6 +79,7 @@ public class Member extends BaseTimeEntity { public void completeSignUp(SignUpMemberRequest signUpMemberRequest) { this.nickname = signUpMemberRequest.nickname(); this.gender = signUpMemberRequest.gender(); + this.role = MemberRole.ROLE_USER; this.status = MemberStatus.ACTIVE; } From 63190c77832bfe4e33177e46414dd96713fbce10 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Tue, 3 Jun 2025 23:00:05 +0900 Subject: [PATCH 7/7] Refactor OAuthLoginService and Member class for improved validation and error handling - Simplified the authentication process by consolidating validation logic into the OAuthLoginService. - Removed redundant validation methods from the Member class and introduced a new method in OAuthLoginService for better clarity. - Enhanced error handling by throwing specific exceptions without additional messages, streamlining the authentication flow. - Updated Javadoc comments for consistency and clarity. These changes aim to improve code maintainability and enhance the overall structure of the authentication system. --- .../auth/service/OAuthLoginService.java | 40 +++++++++++++------ .../juu/juulabel/member/domain/Member.java | 17 -------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java b/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java index d4b17fe..8e24cef 100644 --- a/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java +++ b/src/main/java/com/juu/juulabel/auth/service/OAuthLoginService.java @@ -32,27 +32,24 @@ public class OAuthLoginService { /** * Performs OAuth authentication and returns user info + * * @param provider OAuth provider - * @param code Authorization code + * @param code Authorization code * @return OAuth user information */ public OAuthUser authenticateWithProvider(Provider provider, String code) { try { String redirectUrl = redirectProperties.getRedirectUrl(provider); - OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, code, redirectUrl); - - log.debug("OAuth authentication successful for provider: {} email: {}", - provider, oAuthUser.email()); - return oAuthUser; - + return providerFactory.getOAuthUser(provider, code, redirectUrl); + } catch (Exception e) { - log.error("OAuth authentication failed for provider: {} - {}", provider, e.getMessage()); - throw new AuthException("OAuth authentication failed", ErrorCode.INVALID_AUTHENTICATION); + throw new AuthException(ErrorCode.INVALID_AUTHENTICATION); } } /** * Determines the member status for OAuth user + * * @param oAuthUser OAuth user information * @return Member status result */ @@ -74,15 +71,32 @@ public MemberStatusResult determineMemberStatus(OAuthUser oAuthUser) { */ private void validateMemberForLogin(Member member, OAuthUser oAuthUser) { if (member.getStatus() == MemberStatus.WITHDRAWAL) { - throw new AuthException("Member has been withdrawn", ErrorCode.MEMBER_WITHDRAWN); + throw new AuthException(ErrorCode.MEMBER_WITHDRAWN); } if (member.getStatus() == MemberStatus.INACTIVE) { - throw new AuthException("Member is not active", ErrorCode.MEMBER_NOT_ACTIVE); + throw new AuthException(ErrorCode.MEMBER_NOT_ACTIVE); } // Validate OAuth user matches member - member.validateLoginMember(oAuthUser); + validateLoginMember(member, oAuthUser); + } + + public void validateLoginMember(Member member, OAuthUser oAuthUser) { + if (member.getDeletedAt() != null) { + throw new AuthException(ErrorCode.MEMBER_WITHDRAWN); + } + + if (member.getStatus() == MemberStatus.INACTIVE) { + throw new AuthException(ErrorCode.MEMBER_NOT_ACTIVE); + } + + if (!member.getProvider().equals(oAuthUser.provider())) { + throw new AuthException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + if (!member.getProviderId().equals(oAuthUser.id())) { + throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); + } } /** @@ -102,4 +116,4 @@ public boolean isActiveMember() { return status == MemberStatus.ACTIVE; } } -} \ No newline at end of file +} \ 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 41fc8ea..c6fb98a 100644 --- a/src/main/java/com/juu/juulabel/member/domain/Member.java +++ b/src/main/java/com/juu/juulabel/member/domain/Member.java @@ -112,23 +112,6 @@ public boolean isSameUser(Member other) { return this.equals(other); } - public void validateLoginMember(OAuthUser oAuthUser) { - if (this.deletedAt != null) { - throw new BaseException(ErrorCode.MEMBER_WITHDRAWN); - } - - if (this.status == MemberStatus.INACTIVE ) { - throw new BaseException(ErrorCode.MEMBER_NOT_ACTIVE); - } - - if (!this.provider.equals(oAuthUser.provider())) { - throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE); - } - if (!this.providerId.equals(oAuthUser.id())) { - throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH); - } - } - @Override public boolean equals(Object obj) { if (this == obj)