From e2a289654e3733c3bd6dc7d3e815ffee0550e344 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 20:07:14 +0900 Subject: [PATCH 01/46] =?UTF-8?q?feat(auth):=20Spring=20Security=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index af69a1c6..279273c0 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,9 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + //Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' implementation 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.41' From ba501032b0952ee3ff6f5dadf360bfaef8913c80 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:00:32 +0900 Subject: [PATCH 02/46] =?UTF-8?q?feat(auth):=20jwt,=20redis=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 279273c0..e9174311 100644 --- a/build.gradle +++ b/build.gradle @@ -42,9 +42,17 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" - //Spring Security + // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' implementation 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.41' From 52c79f39eb99b7deacf12815b7e8d06724f58123 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:02:06 +0900 Subject: [PATCH 03/46] =?UTF-8?q?feat(auth):=20auth,=20redis=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-auth.yaml | 8 ++++++++ src/main/resources/application-redis.yaml | 6 ++++++ src/main/resources/application.yaml | 2 ++ 3 files changed, 16 insertions(+) create mode 100644 src/main/resources/application-auth.yaml create mode 100644 src/main/resources/application-redis.yaml diff --git a/src/main/resources/application-auth.yaml b/src/main/resources/application-auth.yaml new file mode 100644 index 00000000..3b0a14d7 --- /dev/null +++ b/src/main/resources/application-auth.yaml @@ -0,0 +1,8 @@ +jwt: + secret-key: ${JWT_SECRET_KEY:defaultSecretKeyForDevelopmentOnlyPleaseChangeInProduction1234567890} + access-token-expiration: 1800000 # 30분 (밀리초) + refresh-token-expiration: 1209600000 # 14일 (밀리초) + +social: + apple: + client-id: ${APPLE_CLIENT_ID:com.sopt.cherrish} # iOS Bundle ID diff --git a/src/main/resources/application-redis.yaml b/src/main/resources/application-redis.yaml new file mode 100644 index 00000000..38a1d6f7 --- /dev/null +++ b/src/main/resources/application-redis.yaml @@ -0,0 +1,6 @@ +spring: + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e5483e7a..4b267f4d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,6 +7,8 @@ spring: include: - db - openai + - auth + - redis config: import: optional:file:.env[.properties] From 61b61e10e193fbbd9d738ca3685331a6d4181f36 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:05:35 +0900 Subject: [PATCH 04/46] =?UTF-8?q?feat(auth):=20user=EC=97=90=20Social=20Pr?= =?UTF-8?q?ovider=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/domain/model/SocialProvider.java | 6 ++++++ .../domain/user/domain/model/User.java | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/domain/model/SocialProvider.java diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/model/SocialProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/model/SocialProvider.java new file mode 100644 index 00000000..cf15900b --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/model/SocialProvider.java @@ -0,0 +1,6 @@ +package com.sopt.cherrish.domain.auth.domain.model; + +public enum SocialProvider { + KAKAO, + APPLE +} diff --git a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java index 765f814c..6c9c3d06 100644 --- a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java +++ b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java @@ -1,9 +1,12 @@ package com.sopt.cherrish.domain.user.domain.model; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.global.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -29,10 +32,23 @@ public class User extends BaseTimeEntity { @Column(nullable = false) private int age; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private SocialProvider socialProvider; + + @Column(nullable = false, unique = true) + private String socialId; + + @Column(unique = true) + private String email; + @Builder - private User(String name, int age) { + private User(String name, int age, SocialProvider socialProvider, String socialId, String email) { this.name = name; this.age = age; + this.socialProvider = socialProvider; + this.socialId = socialId; + this.email = email; } public void update(String name, Integer age) { From ca2796dcdb480c346dd2dfd71fb69db32d477101 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:07:53 +0900 Subject: [PATCH 05/46] =?UTF-8?q?feat(global):=20redis=20config=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/UserRepository.java | 7 ++++++ .../cherrish/global/config/RedisConfig.java | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/java/com/sopt/cherrish/global/config/RedisConfig.java diff --git a/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java b/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java index 470f9233..91a383d6 100644 --- a/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/com/sopt/cherrish/domain/user/domain/repository/UserRepository.java @@ -1,8 +1,15 @@ package com.sopt.cherrish.domain.user.domain.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; public interface UserRepository extends JpaRepository { + + Optional findBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); + + boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); } diff --git a/src/main/java/com/sopt/cherrish/global/config/RedisConfig.java b/src/main/java/com/sopt/cherrish/global/config/RedisConfig.java new file mode 100644 index 00000000..f44f3e23 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/config/RedisConfig.java @@ -0,0 +1,22 @@ +package com.sopt.cherrish.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + return template; + } +} From 7c0a23adcc038d0455afdb2178165e13cbbe9ed1 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:08:14 +0900 Subject: [PATCH 06/46] =?UTF-8?q?feat(auth):=20auth=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20repository=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/RefreshTokenRepository.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..1d1a79b6 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java @@ -0,0 +1,39 @@ +package com.sopt.cherrish.domain.auth.domain.repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepository { + + private static final String KEY_PREFIX = "refresh_token:"; + + private final RedisTemplate redisTemplate; + + public void save(Long userId, String refreshToken, long expirationMillis) { + String key = KEY_PREFIX + userId; + redisTemplate.opsForValue().set(key, refreshToken, expirationMillis, TimeUnit.MILLISECONDS); + } + + public Optional findByUserId(Long userId) { + String key = KEY_PREFIX + userId; + String token = redisTemplate.opsForValue().get(key); + return Optional.ofNullable(token); + } + + public void deleteByUserId(Long userId) { + String key = KEY_PREFIX + userId; + redisTemplate.delete(key); + } + + public boolean existsByUserId(Long userId) { + String key = KEY_PREFIX + userId; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } +} From 03e785dc006e37d27f5e77cedd8a9ced4bce0a8d Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:13:41 +0900 Subject: [PATCH 07/46] =?UTF-8?q?feat(auth):=20auth=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/exception/AuthErrorCode.java | 28 +++++ .../domain/auth/exception/AuthException.java | 10 ++ .../jwt/JwtAuthenticationFilter.java | 76 ++++++++++++ .../infrastructure/jwt/JwtProperties.java | 18 +++ .../infrastructure/jwt/JwtTokenProvider.java | 110 ++++++++++++++++++ .../global/config/RestTemplateConfig.java | 20 ++++ .../global/config/SecurityConfig.java | 68 +++++++++++ .../cherrish/global/security/CurrentUser.java | 14 +++ .../security/JwtAccessDeniedHandler.java | 38 ++++++ .../security/JwtAuthenticationEntryPoint.java | 38 ++++++ .../global/security/UserPrincipal.java | 65 +++++++++++ 11 files changed, 485 insertions(+) create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/exception/AuthErrorCode.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/exception/AuthException.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/sopt/cherrish/global/config/RestTemplateConfig.java create mode 100644 src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java create mode 100644 src/main/java/com/sopt/cherrish/global/security/CurrentUser.java create mode 100644 src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java diff --git a/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthErrorCode.java new file mode 100644 index 00000000..9f2d23a3 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,28 @@ +package com.sopt.cherrish.domain.auth.exception; + +import com.sopt.cherrish.global.response.error.ErrorType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorType { + + UNAUTHORIZED("A001", "인증이 필요합니다", 401), + INVALID_TOKEN("A002", "유효하지 않은 토큰입니다", 401), + TOKEN_EXPIRED("A003", "만료된 토큰입니다", 401), + ACCESS_DENIED("A004", "접근 권한이 없습니다", 403), + USER_NOT_FOUND("A005", "사용자를 찾을 수 없습니다", 404), + + INVALID_SOCIAL_TOKEN("A010", "유효하지 않은 소셜 토큰입니다", 401), + SOCIAL_AUTH_FAILED("A011", "소셜 인증에 실패했습니다", 401), + UNSUPPORTED_SOCIAL_PROVIDER("A012", "지원하지 않는 소셜 로그인 제공자입니다", 400), + + INVALID_REFRESH_TOKEN("A020", "유효하지 않은 리프레시 토큰입니다", 401), + REFRESH_TOKEN_NOT_FOUND("A021", "리프레시 토큰이 존재하지 않습니다", 401); + + private final String code; + private final String message; + private final int status; +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthException.java b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthException.java new file mode 100644 index 00000000..49e4b1fa --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/exception/AuthException.java @@ -0,0 +1,10 @@ +package com.sopt.cherrish.domain.auth.exception; + +import com.sopt.cherrish.global.exception.BaseException; + +public class AuthException extends BaseException { + + public AuthException(AuthErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..8925f99a --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,76 @@ +package com.sopt.cherrish.domain.auth.infrastructure.jwt; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; +import com.sopt.cherrish.domain.user.domain.model.User; +import com.sopt.cherrish.domain.user.domain.repository.UserRepository; +import com.sopt.cherrish.global.security.UserPrincipal; + +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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String token = resolveToken(request); + + if (token != null) { + try { + if (jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserId(token); + User user = userRepository.findById(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.USER_NOT_FOUND)); + + UserPrincipal userPrincipal = UserPrincipal.from(user); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, + null, + userPrincipal.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (AuthException e) { + log.debug("JWT authentication failed: {}", e.getMessage()); + } + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java new file mode 100644 index 00000000..f5a8fe09 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java @@ -0,0 +1,18 @@ +package com.sopt.cherrish.domain.auth.infrastructure.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private String secretKey; + private long accessTokenExpiration; + private long refreshTokenExpiration; +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..31ca1de0 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -0,0 +1,110 @@ +package com.sopt.cherrish.domain.auth.infrastructure.jwt; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.stereotype.Component; + +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String TOKEN_TYPE_CLAIM = "type"; + private static final String ACCESS_TOKEN_TYPE = "access"; + private static final String REFRESH_TOKEN_TYPE = "refresh"; + + private final JwtProperties jwtProperties; + private SecretKey secretKey; + + @PostConstruct + protected void init() { + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey()); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + public String createAccessToken(Long userId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + jwtProperties.getAccessTokenExpiration()); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(Long userId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + jwtProperties.getRefreshTokenExpiration()); + + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + public Long getUserId(String token) { + Claims claims = parseClaims(token); + return Long.parseLong(claims.getSubject()); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (ExpiredJwtException e) { + log.debug("Expired JWT token: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.TOKEN_EXPIRED); + } catch (UnsupportedJwtException e) { + log.debug("Unsupported JWT token: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } catch (MalformedJwtException e) { + log.debug("Malformed JWT token: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } catch (SecurityException e) { + log.debug("Invalid JWT signature: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } catch (IllegalArgumentException e) { + log.debug("JWT claims string is empty: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + } + + public boolean isRefreshToken(String token) { + Claims claims = parseClaims(token); + return REFRESH_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); + } + + public long getRefreshTokenExpiration() { + return jwtProperties.getRefreshTokenExpiration(); + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/config/RestTemplateConfig.java b/src/main/java/com/sopt/cherrish/global/config/RestTemplateConfig.java new file mode 100644 index 00000000..45acc05d --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/config/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package com.sopt.cherrish.global.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java new file mode 100644 index 00000000..fcea5f26 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java @@ -0,0 +1,68 @@ +package com.sopt.cherrish.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtAuthenticationFilter; +import com.sopt.cherrish.global.security.JwtAccessDeniedHandler; +import com.sopt.cherrish.global.security.JwtAuthenticationEntryPoint; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health" + ).permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java b/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java new file mode 100644 index 00000000..4e2b7e3f --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java @@ -0,0 +1,14 @@ +package com.sopt.cherrish.global.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@AuthenticationPrincipal +public @interface CurrentUser { +} diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..3b9ec2f8 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package com.sopt.cherrish.global.security; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.global.response.CommonApiResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + CommonApiResponse errorResponse = CommonApiResponse.fail(AuthErrorCode.ACCESS_DENIED); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..e09e7c48 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.sopt.cherrish.global.security; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.global.response.CommonApiResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + CommonApiResponse errorResponse = CommonApiResponse.fail(AuthErrorCode.UNAUTHORIZED); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java b/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java new file mode 100644 index 00000000..84da073f --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java @@ -0,0 +1,65 @@ +package com.sopt.cherrish.global.security; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.sopt.cherrish.domain.user.domain.model.User; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class UserPrincipal implements UserDetails { + + private final Long userId; + private final String name; + private final Collection authorities; + + public static UserPrincipal from(User user) { + return new UserPrincipal( + user.getId(), + user.getName(), + Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) + ); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} From 4552201fa98c820d9956f10154efc6bdda52943f Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:17:19 +0900 Subject: [PATCH 08/46] =?UTF-8?q?feat(auth):=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/service/AuthService.java | 114 ++++++++++++++++ .../service/SocialLoginService.java | 25 ++++ .../repository/RefreshTokenRepository.java | 2 +- .../social/AppleAuthClient.java | 123 ++++++++++++++++++ .../social/KakaoAuthClient.java | 82 ++++++++++++ .../social/SocialAuthClient.java | 6 + .../infrastructure/social/SocialUserInfo.java | 8 ++ .../auth/presentation/AuthController.java | 72 ++++++++++ .../dto/request/SocialLoginRequestDto.java | 22 ++++ .../dto/request/TokenRefreshRequestDto.java | 15 +++ .../dto/response/LoginResponseDto.java | 20 +++ .../dto/response/TokenResponseDto.java | 14 ++ .../response/success/AuthSuccessCode.java | 18 +++ 13 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/SocialLoginRequestDto.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/TokenRefreshRequestDto.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/LoginResponseDto.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/TokenResponseDto.java create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/response/success/AuthSuccessCode.java diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java new file mode 100644 index 00000000..7d4fae65 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java @@ -0,0 +1,114 @@ +package com.sopt.cherrish.domain.auth.application.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sopt.cherrish.domain.auth.domain.repository.RefreshTokenRepository; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtTokenProvider; +import com.sopt.cherrish.domain.auth.infrastructure.social.SocialUserInfo; +import com.sopt.cherrish.domain.auth.presentation.dto.request.SocialLoginRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.request.TokenRefreshRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.LoginResponseDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.TokenResponseDto; +import com.sopt.cherrish.domain.user.domain.model.User; +import com.sopt.cherrish.domain.user.domain.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private static final String DEFAULT_NAME = "사용자"; + private static final int DEFAULT_AGE = 0; + + private final SocialLoginService socialLoginService; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public LoginResponseDto login(SocialLoginRequestDto request) { + SocialUserInfo socialUserInfo = socialLoginService.authenticate( + request.provider(), + request.token() + ); + + Optional existingUser = userRepository.findBySocialProviderAndSocialId( + request.provider(), + socialUserInfo.socialId() + ); + + boolean isNewUser = existingUser.isEmpty(); + User user; + + if (isNewUser) { + user = User.builder() + .name(DEFAULT_NAME) + .age(DEFAULT_AGE) + .socialProvider(request.provider()) + .socialId(socialUserInfo.socialId()) + .email(socialUserInfo.email()) + .build(); + user = userRepository.save(user); + } else { + user = existingUser.get(); + } + + String accessToken = jwtTokenProvider.createAccessToken(user.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + + refreshTokenRepository.save( + user.getId(), + refreshToken, + jwtTokenProvider.getRefreshTokenExpiration() + ); + + return new LoginResponseDto(user.getId(), isNewUser, accessToken, refreshToken); + } + + @Transactional + public TokenResponseDto refresh(TokenRefreshRequestDto request) { + String refreshToken = request.refreshToken(); + + jwtTokenProvider.validateToken(refreshToken); + + if (!jwtTokenProvider.isRefreshToken(refreshToken)) { + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + Long userId = jwtTokenProvider.getUserId(refreshToken); + + if (!userRepository.existsById(userId)) { + throw new AuthException(AuthErrorCode.USER_NOT_FOUND); + } + + String storedToken = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND)); + + if (!storedToken.equals(refreshToken)) { + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + String newAccessToken = jwtTokenProvider.createAccessToken(userId); + String newRefreshToken = jwtTokenProvider.createRefreshToken(userId); + + refreshTokenRepository.save( + userId, + newRefreshToken, + jwtTokenProvider.getRefreshTokenExpiration() + ); + + return new TokenResponseDto(newAccessToken, newRefreshToken); + } + + @Transactional + public void logout(Long userId) { + refreshTokenRepository.deleteByUserId(userId); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java new file mode 100644 index 00000000..2c33eb99 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java @@ -0,0 +1,25 @@ +package com.sopt.cherrish.domain.auth.application.service; + +import org.springframework.stereotype.Service; + +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; +import com.sopt.cherrish.domain.auth.infrastructure.social.AppleAuthClient; +import com.sopt.cherrish.domain.auth.infrastructure.social.KakaoAuthClient; +import com.sopt.cherrish.domain.auth.infrastructure.social.SocialUserInfo; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SocialLoginService { + + private final KakaoAuthClient kakaoAuthClient; + private final AppleAuthClient appleAuthClient; + + public SocialUserInfo authenticate(SocialProvider provider, String token) { + return switch (provider) { + case KAKAO -> kakaoAuthClient.getUserInfo(token); + case APPLE -> appleAuthClient.getUserInfo(token); + }; + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java index 1d1a79b6..1eabdd08 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java @@ -34,6 +34,6 @@ public void deleteByUserId(Long userId) { public boolean existsByUserId(Long userId) { String key = KEY_PREFIX + userId; - return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + return redisTemplate.hasKey(key); } } diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java new file mode 100644 index 00000000..4b5770c9 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java @@ -0,0 +1,123 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleAuthClient implements SocialAuthClient { + + private static final String APPLE_PUBLIC_KEY_URL = "https://appleid.apple.com/auth/keys"; + private static final String APPLE_ISSUER = "https://appleid.apple.com"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${social.apple.client-id}") + private String clientId; + + @Override + public SocialUserInfo getUserInfo(String identityToken) { + try { + ApplePublicKeys applePublicKeys = restTemplate.getForObject( + APPLE_PUBLIC_KEY_URL, + ApplePublicKeys.class + ); + + if (applePublicKeys == null || applePublicKeys.keys() == null) { + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + + String[] tokenParts = identityToken.split("\\."); + if (tokenParts.length != 3) { + throw new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN); + } + + String headerJson = new String(Base64.getUrlDecoder().decode(tokenParts[0])); + JsonNode headerNode = objectMapper.readTree(headerJson); + String kid = headerNode.get("kid").asText(); + String alg = headerNode.get("alg").asText(); + + ApplePublicKey matchedKey = applePublicKeys.keys().stream() + .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) + .findFirst() + .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN)); + + PublicKey publicKey = generatePublicKey(matchedKey); + + Claims claims = Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(APPLE_ISSUER) + .requireAudience(clientId) + .build() + .parseSignedClaims(identityToken) + .getPayload(); + + return new SocialUserInfo( + claims.getSubject(), + claims.get("email", String.class), + null + ); + } catch (ExpiredJwtException e) { + log.error("Apple identity token expired"); + throw new AuthException(AuthErrorCode.TOKEN_EXPIRED); + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("Apple auth error: {}", e.getMessage(), e); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private PublicKey generatePublicKey(ApplePublicKey appleKey) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(appleKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(appleKey.e()); + + BigInteger modulus = new BigInteger(1, nBytes); + BigInteger exponent = new BigInteger(1, eBytes); + + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + KeyFactory factory = KeyFactory.getInstance("RSA"); + return factory.generatePublic(spec); + } catch (Exception e) { + log.error("Failed to generate Apple public key: {}", e.getMessage(), e); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private record ApplePublicKeys( + List keys + ) { + } + + private record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e + ) { + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java new file mode 100644 index 00000000..969ac8aa --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java @@ -0,0 +1,82 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.exception.AuthException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KakaoAuthClient implements SocialAuthClient { + + private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; + + private final RestTemplate restTemplate; + + @Override + public SocialUserInfo getUserInfo(String accessToken) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + KAKAO_USER_INFO_URL, + HttpMethod.GET, + entity, + KakaoUserResponse.class + ); + + KakaoUserResponse body = response.getBody(); + if (body == null) { + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + + return new SocialUserInfo( + String.valueOf(body.id()), + body.kakaoAccount() != null ? body.kakaoAccount().email() : null, + body.properties() != null ? body.properties().nickname() : null + ); + } catch (HttpClientErrorException e) { + log.error("Kakao token validation failed: status={}, body={}", + e.getStatusCode(), e.getResponseBodyAsString()); + throw new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN); + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("Kakao auth error: {}", e.getMessage(), e); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private record KakaoUserResponse( + Long id, + @JsonProperty("kakao_account") KakaoAccount kakaoAccount, + KakaoProperties properties + ) { + } + + private record KakaoAccount( + String email + ) { + } + + private record KakaoProperties( + String nickname + ) { + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java new file mode 100644 index 00000000..528ba3a3 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java @@ -0,0 +1,6 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +public interface SocialAuthClient { + + SocialUserInfo getUserInfo(String token); +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java new file mode 100644 index 00000000..e3507aae --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java @@ -0,0 +1,8 @@ +package com.sopt.cherrish.domain.auth.infrastructure.social; + +public record SocialUserInfo( + String socialId, + String email, + String nickname +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java new file mode 100644 index 00000000..c3afe020 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java @@ -0,0 +1,72 @@ +package com.sopt.cherrish.domain.auth.presentation; + +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.RestController; + +import com.sopt.cherrish.domain.auth.application.service.AuthService; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.domain.auth.presentation.dto.request.SocialLoginRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.request.TokenRefreshRequestDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.LoginResponseDto; +import com.sopt.cherrish.domain.auth.presentation.dto.response.TokenResponseDto; +import com.sopt.cherrish.domain.auth.response.success.AuthSuccessCode; +import com.sopt.cherrish.global.annotation.ApiExceptions; +import com.sopt.cherrish.global.response.CommonApiResponse; +import com.sopt.cherrish.global.response.error.ErrorCode; +import com.sopt.cherrish.global.security.CurrentUser; +import com.sopt.cherrish.global.security.UserPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "Auth", description = "인증 관련 API") +public class AuthController { + + private final AuthService authService; + + @Operation( + summary = "소셜 로그인", + description = "카카오 또는 애플 소셜 토큰으로 로그인합니다. 신규 사용자는 isNewUser: true를 반환합니다." + ) + @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) + @PostMapping("/login") + public CommonApiResponse login( + @Valid @RequestBody SocialLoginRequestDto request + ) { + LoginResponseDto response = authService.login(request); + return CommonApiResponse.success(AuthSuccessCode.LOGIN_SUCCESS, response); + } + + @Operation( + summary = "토큰 재발급", + description = "Refresh Token으로 새로운 Access Token과 Refresh Token을 발급받습니다." + ) + @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) + @PostMapping("/refresh") + public CommonApiResponse refresh( + @Valid @RequestBody TokenRefreshRequestDto request + ) { + TokenResponseDto response = authService.refresh(request); + return CommonApiResponse.success(AuthSuccessCode.TOKEN_REFRESHED, response); + } + + @Operation( + summary = "로그아웃", + description = "현재 사용자의 Refresh Token을 무효화합니다." + ) + @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) + @PostMapping("/logout") + public CommonApiResponse logout( + @CurrentUser UserPrincipal userPrincipal + ) { + authService.logout(userPrincipal.getUserId()); + return CommonApiResponse.success(AuthSuccessCode.LOGOUT_SUCCESS); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/SocialLoginRequestDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/SocialLoginRequestDto.java new file mode 100644 index 00000000..f314f13a --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/SocialLoginRequestDto.java @@ -0,0 +1,22 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.request; + +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "소셜 로그인 요청") +public record SocialLoginRequestDto( + + @Schema(description = "소셜 로그인 제공자", example = "KAKAO", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "소셜 로그인 제공자는 필수입니다") + SocialProvider provider, + + @Schema(description = "소셜 토큰 (카카오: Access Token, 애플: Identity Token)", + example = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "소셜 토큰은 필수입니다") + String token +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/TokenRefreshRequestDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/TokenRefreshRequestDto.java new file mode 100644 index 00000000..68b7c967 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/request/TokenRefreshRequestDto.java @@ -0,0 +1,15 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "토큰 재발급 요청") +public record TokenRefreshRequestDto( + + @Schema(description = "Refresh Token", + example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "Refresh Token은 필수입니다") + String refreshToken +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/LoginResponseDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/LoginResponseDto.java new file mode 100644 index 00000000..40ccf2da --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/LoginResponseDto.java @@ -0,0 +1,20 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "로그인 응답") +public record LoginResponseDto( + + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "신규 사용자 여부 (true인 경우 온보딩 필요)", example = "false") + boolean isNewUser, + + @Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String accessToken, + + @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String refreshToken +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/TokenResponseDto.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/TokenResponseDto.java new file mode 100644 index 00000000..7d0307fc --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/dto/response/TokenResponseDto.java @@ -0,0 +1,14 @@ +package com.sopt.cherrish.domain.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "토큰 재발급 응답") +public record TokenResponseDto( + + @Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String accessToken, + + @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String refreshToken +) { +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/response/success/AuthSuccessCode.java b/src/main/java/com/sopt/cherrish/domain/auth/response/success/AuthSuccessCode.java new file mode 100644 index 00000000..088f7ac6 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/response/success/AuthSuccessCode.java @@ -0,0 +1,18 @@ +package com.sopt.cherrish.domain.auth.response.success; + +import com.sopt.cherrish.global.response.success.SuccessType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthSuccessCode implements SuccessType { + + LOGIN_SUCCESS("A200", "로그인 성공"), + TOKEN_REFRESHED("A201", "토큰 재발급 성공"), + LOGOUT_SUCCESS("A202", "로그아웃 성공"); + + private final String code; + private final String message; +} From 5862fcfe393a9bbf9fa9db19fb92a76177a201c6 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:30:21 +0900 Subject: [PATCH 09/46] =?UTF-8?q?feat(auth):=20apple=20=EA=B3=B5=EA=B0=9C?= =?UTF-8?q?=20=ED=82=A4=20=EC=BA=90=EC=8B=B1=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../social/AppleAuthClient.java | 90 ++++++++++++++++--- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java index 4b5770c9..0f093d03 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java @@ -6,9 +6,11 @@ import java.security.spec.RSAPublicKeySpec; import java.util.Base64; import java.util.List; +import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import com.fasterxml.jackson.databind.JsonNode; @@ -19,16 +21,15 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Component -@RequiredArgsConstructor public class AppleAuthClient implements SocialAuthClient { private static final String APPLE_PUBLIC_KEY_URL = "https://appleid.apple.com/auth/keys"; private static final String APPLE_ISSUER = "https://appleid.apple.com"; + private static final long CACHE_DURATION_HOURS = 24; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; @@ -36,17 +37,18 @@ public class AppleAuthClient implements SocialAuthClient { @Value("${social.apple.client-id}") private String clientId; + private volatile ApplePublicKeys cachedKeys; + private volatile long cacheExpireTime; + + public AppleAuthClient(RestTemplate restTemplate, ObjectMapper objectMapper) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + } + @Override public SocialUserInfo getUserInfo(String identityToken) { try { - ApplePublicKeys applePublicKeys = restTemplate.getForObject( - APPLE_PUBLIC_KEY_URL, - ApplePublicKeys.class - ); - - if (applePublicKeys == null || applePublicKeys.keys() == null) { - throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); - } + ApplePublicKeys applePublicKeys = getApplePublicKeys(); String[] tokenParts = identityToken.split("\\."); if (tokenParts.length != 3) { @@ -58,10 +60,17 @@ public SocialUserInfo getUserInfo(String identityToken) { String kid = headerNode.get("kid").asText(); String alg = headerNode.get("alg").asText(); - ApplePublicKey matchedKey = applePublicKeys.keys().stream() - .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) - .findFirst() - .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN)); + ApplePublicKey matchedKey = findMatchingKey(applePublicKeys, kid, alg); + + if (matchedKey == null) { + log.info("Apple public key not found in cache, refreshing keys for kid: {}", kid); + applePublicKeys = refreshApplePublicKeys(); + matchedKey = findMatchingKey(applePublicKeys, kid, alg); + + if (matchedKey == null) { + throw new AuthException(AuthErrorCode.INVALID_SOCIAL_TOKEN); + } + } PublicKey publicKey = generatePublicKey(matchedKey); @@ -106,6 +115,59 @@ private PublicKey generatePublicKey(ApplePublicKey appleKey) { } } + private ApplePublicKeys getApplePublicKeys() { + long now = System.currentTimeMillis(); + + if (cachedKeys != null && now < cacheExpireTime) { + return cachedKeys; + } + + return refreshApplePublicKeys(); + } + + private synchronized ApplePublicKeys refreshApplePublicKeys() { + long now = System.currentTimeMillis(); + + if (cachedKeys != null && now < cacheExpireTime) { + return cachedKeys; + } + + try { + ApplePublicKeys keys = restTemplate.getForObject( + APPLE_PUBLIC_KEY_URL, + ApplePublicKeys.class + ); + + if (keys == null || keys.keys() == null) { + if (cachedKeys != null) { + log.warn("Failed to fetch Apple public keys, using cached keys"); + return cachedKeys; + } + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + + cachedKeys = keys; + cacheExpireTime = now + TimeUnit.HOURS.toMillis(CACHE_DURATION_HOURS); + log.info("Apple public keys cached successfully, {} keys loaded", keys.keys().size()); + + return cachedKeys; + } catch (RestClientException e) { + if (cachedKeys != null) { + log.warn("Failed to refresh Apple public keys: {}, using cached keys", e.getMessage()); + return cachedKeys; + } + log.error("Failed to fetch Apple public keys and no cache available: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.SOCIAL_AUTH_FAILED); + } + } + + private ApplePublicKey findMatchingKey(ApplePublicKeys keys, String kid, String alg) { + return keys.keys().stream() + .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) + .findFirst() + .orElse(null); + } + private record ApplePublicKeys( List keys ) { From bae49fec0299d91cfb94116aff69f76f3cb4c5bb Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 25 Jan 2026 21:35:56 +0900 Subject: [PATCH 10/46] =?UTF-8?q?feat(auth):=20validateToken=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=ED=83=80=EC=9E=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/JwtAuthenticationFilter.java | 28 +++++++++---------- .../infrastructure/jwt/JwtTokenProvider.java | 3 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java index 8925f99a..e60dfa07 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -43,21 +43,21 @@ protected void doFilterInternal( if (token != null) { try { - if (jwtTokenProvider.validateToken(token)) { - Long userId = jwtTokenProvider.getUserId(token); - User user = userRepository.findById(userId) - .orElseThrow(() -> new AuthException(AuthErrorCode.USER_NOT_FOUND)); + jwtTokenProvider.validateToken(token); - UserPrincipal userPrincipal = UserPrincipal.from(user); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - userPrincipal, - null, - userPrincipal.getAuthorities() - ); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - } + Long userId = jwtTokenProvider.getUserId(token); + User user = userRepository.findById(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.USER_NOT_FOUND)); + + UserPrincipal userPrincipal = UserPrincipal.from(user); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, + null, + userPrincipal.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); } catch (AuthException e) { log.debug("JWT authentication failed: {}", e.getMessage()); } diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index 31ca1de0..a5f7d0f3 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -69,10 +69,9 @@ public Long getUserId(String token) { return Long.parseLong(claims.getSubject()); } - public boolean validateToken(String token) { + public void validateToken(String token) { try { parseClaims(token); - return true; } catch (ExpiredJwtException e) { log.debug("Expired JWT token: {}", e.getMessage()); throw new AuthException(AuthErrorCode.TOKEN_EXPIRED); From cc29146393e68352ca92b0d976bf6fb4334da085 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 00:14:46 +0900 Subject: [PATCH 11/46] =?UTF-8?q?docs(auth):=20javaDocs=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/service/AuthService.java | 31 +++++++++++++ .../service/SocialLoginService.java | 13 ++++++ .../repository/RefreshTokenRepository.java | 34 +++++++++++++++ .../jwt/JwtAuthenticationFilter.java | 10 +++++ .../infrastructure/jwt/JwtTokenProvider.java | 43 +++++++++++++++++++ .../social/AppleAuthClient.java | 17 ++++++++ .../social/KakaoAuthClient.java | 16 +++++++ .../social/SocialAuthClient.java | 12 ++++++ .../infrastructure/social/SocialUserInfo.java | 7 +++ .../global/config/SecurityConfig.java | 7 +++ .../cherrish/global/security/CurrentUser.java | 13 ++++++ .../security/JwtAccessDeniedHandler.java | 5 +++ .../security/JwtAuthenticationEntryPoint.java | 6 +++ .../global/security/UserPrincipal.java | 12 ++++++ 14 files changed, 226 insertions(+) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java index 7d4fae65..d779fa4a 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java @@ -19,6 +19,11 @@ import lombok.RequiredArgsConstructor; +/** + * 인증 관련 비즈니스 로직을 처리하는 서비스. + * + *

소셜 로그인, 토큰 재발급, 로그아웃 기능을 제공합니다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -32,6 +37,16 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; + /** + * 소셜 로그인을 처리합니다. + * + *

소셜 토큰을 검증하고, 신규 사용자인 경우 회원가입을 진행합니다. + * 이후 Access Token과 Refresh Token을 발급합니다.

+ * + * @param request 소셜 로그인 요청 (provider, token) + * @return 로그인 응답 (userId, isNewUser, accessToken, refreshToken) + * @throws AuthException 소셜 토큰이 유효하지 않은 경우 + */ @Transactional public LoginResponseDto login(SocialLoginRequestDto request) { SocialUserInfo socialUserInfo = socialLoginService.authenticate( @@ -72,6 +87,15 @@ public LoginResponseDto login(SocialLoginRequestDto request) { return new LoginResponseDto(user.getId(), isNewUser, accessToken, refreshToken); } + /** + * Refresh Token으로 새로운 토큰 쌍을 발급합니다. + * + *

Refresh Token Rotation(RTR) 방식을 적용하여 재발급시 새로운 Refresh Token도 함께 발급합니다.

+ * + * @param request 토큰 재발급 요청 (refreshToken) + * @return 새로운 Access Token과 Refresh Token + * @throws AuthException Refresh Token이 유효하지 않거나 만료된 경우 + */ @Transactional public TokenResponseDto refresh(TokenRefreshRequestDto request) { String refreshToken = request.refreshToken(); @@ -107,6 +131,13 @@ public TokenResponseDto refresh(TokenRefreshRequestDto request) { return new TokenResponseDto(newAccessToken, newRefreshToken); } + /** + * 로그아웃을 처리합니다. + * + *

Redis에 저장된 Refresh Token을 삭제하여 해당 토큰으로 더 이상 재발급이 불가능하게 합니다.

+ * + * @param userId 로그아웃할 사용자 ID + */ @Transactional public void logout(Long userId) { refreshTokenRepository.deleteByUserId(userId); diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java index 2c33eb99..43a115c5 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/SocialLoginService.java @@ -9,6 +9,11 @@ import lombok.RequiredArgsConstructor; +/** + * 소셜 로그인 인증을 처리하는 서비스. + * + *

소셜 플랫폼에 따라 적절한 인증 클라이언트를 선택하여 토큰을 검증합니다.

+ */ @Service @RequiredArgsConstructor public class SocialLoginService { @@ -16,6 +21,14 @@ public class SocialLoginService { private final KakaoAuthClient kakaoAuthClient; private final AppleAuthClient appleAuthClient; + /** + * 소셜 토큰을 검증하고 사용자 정보를 반환합니다. + * + * @param provider 소셜 플랫폼 (KAKAO, APPLE) + * @param token 소셜 플랫폼에서 발급한 토큰 + * @return 소셜 사용자 정보 + * @throws com.sopt.cherrish.domain.auth.exception.AuthException 토큰이 유효하지 않은 경우 + */ public SocialUserInfo authenticate(SocialProvider provider, String token) { return switch (provider) { case KAKAO -> kakaoAuthClient.getUserInfo(token); diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java index 1eabdd08..7765a9b2 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java @@ -8,6 +8,12 @@ import lombok.RequiredArgsConstructor; +/** + * Refresh Token을 Redis에 저장하고 관리하는 저장소. + * + *

사용자별로 하나의 Refresh Token만 유효하도록 관리합니다. + * TTL이 설정되어 만료된 토큰은 자동으로 삭제됩니다.

+ */ @Repository @RequiredArgsConstructor public class RefreshTokenRepository { @@ -16,22 +22,50 @@ public class RefreshTokenRepository { private final RedisTemplate redisTemplate; + /** + * Refresh Token을 저장합니다. + * + *

기존에 저장된 토큰이 있으면 덮어씁니다.

+ * + * @param userId 사용자 ID + * @param refreshToken 저장할 Refresh Token + * @param expirationMillis 만료 시간 (밀리초) + */ public void save(Long userId, String refreshToken, long expirationMillis) { String key = KEY_PREFIX + userId; redisTemplate.opsForValue().set(key, refreshToken, expirationMillis, TimeUnit.MILLISECONDS); } + /** + * 사용자의 Refresh Token을 조회합니다. + * + * @param userId 사용자 ID + * @return 저장된 Refresh Token, 없으면 빈 Optional + */ public Optional findByUserId(Long userId) { String key = KEY_PREFIX + userId; String token = redisTemplate.opsForValue().get(key); return Optional.ofNullable(token); } + /** + * 사용자의 Refresh Token을 삭제합니다. + * + *

로그아웃 시 호출되어 해당 토큰을 무효화합니다.

+ * + * @param userId 사용자 ID + */ public void deleteByUserId(Long userId) { String key = KEY_PREFIX + userId; redisTemplate.delete(key); } + /** + * 사용자의 Refresh Token 존재 여부를 확인합니다. + * + * @param userId 사용자 ID + * @return 토큰이 존재하면 true + */ public boolean existsByUserId(Long userId) { String key = KEY_PREFIX + userId; return redisTemplate.hasKey(key); diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java index e60dfa07..d463ef63 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -22,6 +22,16 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +/** + * JWT 기반 인증을 처리하는 필터. + * + *

모든 요청에서 Authorization 헤더의 Bearer 토큰을 추출하여 검증합니다. + * 토큰이 유효하면 SecurityContext에 인증 정보를 설정합니다.

+ * + *

토큰이 없거나 유효하지 않은 경우 인증을 설정하지 않고 다음 필터로 진행합니다. + * 보호된 리소스 접근시 {@link com.sopt.cherrish.global.security.JwtAuthenticationEntryPoint}에서 + * 401 응답을 반환합니다.

+ */ @Slf4j @Component @RequiredArgsConstructor diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index a5f7d0f3..0a31bc30 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -20,6 +20,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +/** + * JWT 토큰 생성 및 검증을 담당하는 컴포넌트. + * + *

Access Token과 Refresh Token을 생성하고, 토큰의 유효성을 검증합니다. + * 토큰에는 사용자 ID와 토큰 타입(access/refresh)이 포함됩니다.

+ */ @Slf4j @Component @RequiredArgsConstructor @@ -38,6 +44,12 @@ protected void init() { this.secretKey = Keys.hmacShaKeyFor(keyBytes); } + /** + * Access Token을 생성합니다. + * + * @param userId 사용자 ID + * @return 생성된 Access Token 문자열 + */ public String createAccessToken(Long userId) { Date now = new Date(); Date expiration = new Date(now.getTime() + jwtProperties.getAccessTokenExpiration()); @@ -51,6 +63,12 @@ public String createAccessToken(Long userId) { .compact(); } + /** + * Refresh Token을 생성합니다. + * + * @param userId 사용자 ID + * @return 생성된 Refresh Token 문자열 + */ public String createRefreshToken(Long userId) { Date now = new Date(); Date expiration = new Date(now.getTime() + jwtProperties.getRefreshTokenExpiration()); @@ -64,11 +82,25 @@ public String createRefreshToken(Long userId) { .compact(); } + /** + * 토큰에서 사용자 ID를 추출합니다. + * + * @param token JWT 토큰 + * @return 사용자 ID + */ public Long getUserId(String token) { Claims claims = parseClaims(token); return Long.parseLong(claims.getSubject()); } + /** + * 토큰의 유효성을 검증합니다. + * + *

토큰이 유효하면 정상 반환되고, 유효하지 않으면 예외가 발생합니다.

+ * + * @param token 검증할 JWT 토큰 + * @throws AuthException 토큰이 만료되었거나 유효하지 않은 경우 + */ public void validateToken(String token) { try { parseClaims(token); @@ -90,11 +122,22 @@ public void validateToken(String token) { } } + /** + * 토큰이 Refresh Token인지 확인합니다. + * + * @param token JWT 토큰 + * @return Refresh Token이면 true, Access Token이면 false + */ public boolean isRefreshToken(String token) { Claims claims = parseClaims(token); return REFRESH_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); } + /** + * Refresh Token의 만료 시간(밀리초)을 반환합니다. + * + * @return Refresh Token 만료 시간 (밀리초) + */ public long getRefreshTokenExpiration() { return jwtProperties.getRefreshTokenExpiration(); } diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java index 0f093d03..761b2eb3 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/AppleAuthClient.java @@ -23,6 +23,14 @@ import io.jsonwebtoken.Jwts; import lombok.extern.slf4j.Slf4j; +/** + * Apple Sign In 토큰 검증 클라이언트. + * + *

Apple Identity Token을 검증하고 사용자 정보를 추출합니다. + * Apple 공개키는 24시간 동안 캐싱되어 성능을 최적화합니다.

+ * + * @see Apple Sign In Documentation + */ @Slf4j @Component public class AppleAuthClient implements SocialAuthClient { @@ -45,6 +53,15 @@ public AppleAuthClient(RestTemplate restTemplate, ObjectMapper objectMapper) { this.objectMapper = objectMapper; } + /** + * Apple Identity Token을 검증하고 사용자 정보를 추출합니다. + * + *

토큰의 서명을 Apple 공개키로 검증하고, issuer와 audience를 확인합니다.

+ * + * @param identityToken Apple에서 발급한 Identity Token (JWT) + * @return 소셜 사용자 정보 (socialId, email) + * @throws AuthException 토큰이 유효하지 않거나 검증에 실패한 경우 + */ @Override public SocialUserInfo getUserInfo(String identityToken) { try { diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java index 969ac8aa..17d2c1a7 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/KakaoAuthClient.java @@ -16,6 +16,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +/** + * 카카오 로그인 토큰 검증 클라이언트. + * + *

카카오 Access Token을 사용하여 카카오 API에서 사용자 정보를 조회합니다.

+ * + * @see 카카오 로그인 API 문서 + */ @Slf4j @Component @RequiredArgsConstructor @@ -25,6 +32,15 @@ public class KakaoAuthClient implements SocialAuthClient { private final RestTemplate restTemplate; + /** + * 카카오 Access Token으로 사용자 정보를 조회합니다. + * + *

카카오 사용자 정보 API(/v2/user/me)를 호출하여 사용자 정보를 가져옵니다.

+ * + * @param accessToken 카카오에서 발급한 Access Token + * @return 소셜 사용자 정보 (socialId, email, nickname) + * @throws AuthException 토큰이 유효하지 않거나 API 호출에 실패한 경우 + */ @Override public SocialUserInfo getUserInfo(String accessToken) { try { diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java index 528ba3a3..eebf2ea2 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialAuthClient.java @@ -1,6 +1,18 @@ package com.sopt.cherrish.domain.auth.infrastructure.social; +/** + * 소셜 로그인 인증 클라이언트 인터페이스. + * + *

각 소셜 플랫폼(카카오, 애플 등)별로 구현체를 제공합니다.

+ */ public interface SocialAuthClient { + /** + * 소셜 토큰을 검증하고 사용자 정보를 조회합니다. + * + * @param token 소셜 플랫폼에서 발급한 토큰 + * @return 소셜 사용자 정보 + * @throws com.sopt.cherrish.domain.auth.exception.AuthException 토큰이 유효하지 않은 경우 + */ SocialUserInfo getUserInfo(String token); } diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java index e3507aae..8590d767 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/social/SocialUserInfo.java @@ -1,5 +1,12 @@ package com.sopt.cherrish.domain.auth.infrastructure.social; +/** + * 소셜 플랫폼에서 조회한 사용자 정보. + * + * @param socialId 소셜 플랫폼의 고유 사용자 ID + * @param email 사용자 이메일 (없을 수 있음) + * @param nickname 사용자 닉네임 (없을 수 있음) + */ public record SocialUserInfo( String socialId, String email, diff --git a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java index fcea5f26..b61f0c01 100644 --- a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java +++ b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java @@ -29,6 +29,13 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + /** + * Security Filter Chain을 구성합니다. + * + * @param http HttpSecurity 설정 객체 + * @return 구성된 SecurityFilterChain + * @throws Exception 설정 중 발생할 수 있는 예외 + */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http diff --git a/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java b/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java index 4e2b7e3f..4064043e 100644 --- a/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java +++ b/src/main/java/com/sopt/cherrish/global/security/CurrentUser.java @@ -7,6 +7,19 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; +/** + * 현재 인증된 사용자 정보를 주입받기 위한 커스텀 어노테이션. + * + *

컨트롤러 메서드 파라미터에 사용하여 {@link UserPrincipal}을 주입받습니다.

+ * + *
{@code
+ * @GetMapping("/me")
+ * public ResponseEntity getMe(@CurrentUser UserPrincipal userPrincipal) {
+ *     Long userId = userPrincipal.getUserId();
+ *     // ...
+ * }
+ * }
+ */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) @AuthenticationPrincipal diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java index 3b9ec2f8..c1828533 100644 --- a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java @@ -16,6 +16,11 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +/** + * 권한이 없는 요청에 대한 처리를 담당하는 Handler. + * + *

인증은 되었으나 해당 리소스에 대한 권한이 없는 경우 403 Forbidden 응답을 반환합니다.

+ */ @Component @RequiredArgsConstructor public class JwtAccessDeniedHandler implements AccessDeniedHandler { diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java index e09e7c48..35e993a5 100644 --- a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java @@ -16,6 +16,12 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +/** + * 인증되지 않은 요청에 대한 처리를 담당하는 EntryPoint. + * + *

JWT 토큰이 없거나 유효하지 않은 경우 401 Unauthorized 응답을 반환합니다. + * Spring Security 필터 체인에서 발생하는 인증 예외를 처리합니다.

+ */ @Component @RequiredArgsConstructor public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { diff --git a/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java b/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java index 84da073f..c13f534a 100644 --- a/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java +++ b/src/main/java/com/sopt/cherrish/global/security/UserPrincipal.java @@ -12,6 +12,12 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +/** + * Spring Security의 UserDetails 구현체. + * + *

JWT 인증 후 SecurityContext에 저장되어 현재 인증된 사용자 정보를 제공합니다. + * {@link CurrentUser} 어노테이션을 통해 컨트롤러에서 주입받을 수 있습니다.

+ */ @Getter @RequiredArgsConstructor public class UserPrincipal implements UserDetails { @@ -20,6 +26,12 @@ public class UserPrincipal implements UserDetails { private final String name; private final Collection authorities; + /** + * User 엔티티로부터 UserPrincipal을 생성합니다. + * + * @param user 사용자 엔티티 + * @return UserPrincipal 인스턴스 + */ public static UserPrincipal from(User user) { return new UserPrincipal( user.getId(), From dadf4d6674c7131c30d52750446a6eac0fef577e Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 00:26:24 +0900 Subject: [PATCH 12/46] =?UTF-8?q?feat(auth):=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/service/AuthService.java | 14 +++++- .../AccessTokenBlacklistRepository.java | 48 +++++++++++++++++++ .../jwt/JwtAuthenticationFilter.java | 8 ++++ .../infrastructure/jwt/JwtTokenProvider.java | 30 ++++++++++++ .../auth/presentation/AuthController.java | 9 ++-- 5 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java index d779fa4a..eaaec8d0 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; import com.sopt.cherrish.domain.auth.domain.repository.RefreshTokenRepository; import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; import com.sopt.cherrish.domain.auth.exception.AuthException; @@ -36,6 +37,7 @@ public class AuthService { private final UserRepository userRepository; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; + private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; /** * 소셜 로그인을 처리합니다. @@ -134,12 +136,20 @@ public TokenResponseDto refresh(TokenRefreshRequestDto request) { /** * 로그아웃을 처리합니다. * - *

Redis에 저장된 Refresh Token을 삭제하여 해당 토큰으로 더 이상 재발급이 불가능하게 합니다.

+ *

Redis에 저장된 Refresh Token을 삭제하고, Access Token을 블랙리스트에 추가하여 + * 해당 토큰들로 더 이상 인증이 불가능하게 합니다.

* * @param userId 로그아웃할 사용자 ID + * @param authorizationHeader Authorization 헤더 값 (Bearer 토큰) */ @Transactional - public void logout(Long userId) { + public void logout(Long userId, String authorizationHeader) { refreshTokenRepository.deleteByUserId(userId); + + String accessToken = jwtTokenProvider.extractToken(authorizationHeader); + if (accessToken != null) { + long remainingExpiration = jwtTokenProvider.getRemainingExpiration(accessToken); + accessTokenBlacklistRepository.add(accessToken, remainingExpiration); + } } } diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java new file mode 100644 index 00000000..895f9ba1 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java @@ -0,0 +1,48 @@ +package com.sopt.cherrish.domain.auth.domain.repository; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +/** + * Access Token 블랙리스트를 Redis에 관리하는 저장소. + * + *

로그아웃된 Access Token을 저장하여 만료 전까지 재사용을 방지합니다. + * TTL은 토큰의 남은 만료 시간으로 설정되어 자동으로 정리됩니다.

+ */ +@Repository +@RequiredArgsConstructor +public class AccessTokenBlacklistRepository { + + private static final String KEY_PREFIX = "blacklist:"; + + private final RedisTemplate redisTemplate; + + /** + * Access Token을 블랙리스트에 추가합니다. + * + * @param token 블랙리스트에 추가할 Access Token + * @param expirationMillis 토큰의 남은 만료 시간 (밀리초) + */ + public void add(String token, long expirationMillis) { + if (expirationMillis <= 0) { + return; + } + String key = KEY_PREFIX + token; + redisTemplate.opsForValue().set(key, "blacklisted", expirationMillis, TimeUnit.MILLISECONDS); + } + + /** + * Access Token이 블랙리스트에 있는지 확인합니다. + * + * @param token 확인할 Access Token + * @return 블랙리스트에 있으면 true + */ + public boolean isBlacklisted(String token) { + String key = KEY_PREFIX + token; + return redisTemplate.hasKey(key); + } +} diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java index d463ef63..dcd249f3 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -9,6 +9,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; import com.sopt.cherrish.domain.auth.exception.AuthException; import com.sopt.cherrish.domain.user.domain.model.User; @@ -42,6 +43,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; @Override protected void doFilterInternal( @@ -55,6 +57,12 @@ protected void doFilterInternal( try { jwtTokenProvider.validateToken(token); + if (accessTokenBlacklistRepository.isBlacklisted(token)) { + log.debug("Token is blacklisted"); + filterChain.doFilter(request, response); + return; + } + Long userId = jwtTokenProvider.getUserId(token); User user = userRepository.findById(userId) .orElseThrow(() -> new AuthException(AuthErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index 0a31bc30..355d19d4 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -142,6 +142,36 @@ public long getRefreshTokenExpiration() { return jwtProperties.getRefreshTokenExpiration(); } + /** + * 토큰의 남은 만료 시간(밀리초)을 반환합니다. + * + * @param token JWT 토큰 + * @return 남은 만료 시간 (밀리초), 이미 만료된 경우 0 + */ + public long getRemainingExpiration(String token) { + try { + Claims claims = parseClaims(token); + Date expiration = claims.getExpiration(); + long remaining = expiration.getTime() - System.currentTimeMillis(); + return Math.max(0, remaining); + } catch (Exception e) { + return 0; + } + } + + /** + * Authorization 헤더에서 Bearer 토큰을 추출합니다. + * + * @param authorizationHeader Authorization 헤더 값 + * @return 추출된 토큰, 유효하지 않은 형식이면 null + */ + public String extractToken(String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return authorizationHeader.substring(7); + } + return null; + } + private Claims parseClaims(String token) { return Jwts.parser() .verifyWith(secretKey) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java index c3afe020..e7edc92c 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java @@ -2,6 +2,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,6 +20,7 @@ import com.sopt.cherrish.global.security.UserPrincipal; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -59,14 +61,15 @@ public CommonApiResponse refresh( @Operation( summary = "로그아웃", - description = "현재 사용자의 Refresh Token을 무효화합니다." + description = "현재 사용자의 Refresh Token을 무효화하고 Access Token을 블랙리스트에 추가합니다." ) @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) @PostMapping("/logout") public CommonApiResponse logout( - @CurrentUser UserPrincipal userPrincipal + @CurrentUser UserPrincipal userPrincipal, + @Parameter(hidden = true) @RequestHeader("Authorization") String authorizationHeader ) { - authService.logout(userPrincipal.getUserId()); + authService.logout(userPrincipal.getUserId(), authorizationHeader); return CommonApiResponse.success(AuthSuccessCode.LOGOUT_SUCCESS); } } From 0276c7fd3e3d4db6b8c799ce01fa46376d2b8ed1 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 00:45:17 +0900 Subject: [PATCH 13/46] =?UTF-8?q?test(redis):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=9A=A9=20redis=20mock=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/TestRedisConfig.java | 36 +++++++++++++++++++ src/test/resources/application.yaml | 22 ++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/test/java/com/sopt/cherrish/global/config/TestRedisConfig.java diff --git a/src/test/java/com/sopt/cherrish/global/config/TestRedisConfig.java b/src/test/java/com/sopt/cherrish/global/config/TestRedisConfig.java new file mode 100644 index 00000000..824c8c28 --- /dev/null +++ b/src/test/java/com/sopt/cherrish/global/config/TestRedisConfig.java @@ -0,0 +1,36 @@ +package com.sopt.cherrish.global.config; + +import org.mockito.Mockito; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +@Configuration +@Profile("test") +public class TestRedisConfig { + + @Bean + @Primary + @SuppressWarnings("unchecked") + public RedisTemplate redisTemplate() { + RedisTemplate mockTemplate = Mockito.mock(RedisTemplate.class); + ValueOperations mockValueOps = Mockito.mock(ValueOperations.class); + + when(mockTemplate.opsForValue()).thenReturn(mockValueOps); + when(mockTemplate.hasKey(anyString())).thenReturn(false); + when(mockTemplate.delete(anyString())).thenReturn(true); + doNothing().when(mockValueOps).set(anyString(), anyString(), anyLong(), any()); + when(mockValueOps.get(anyString())).thenReturn(null); + + return mockTemplate; + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 70200a53..2fd2d19f 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,5 +1,13 @@ spring: + profiles: + active: test + + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver @@ -25,3 +33,17 @@ spring: ai: openai: api-key: test-dummy-api-key-for-unit-tests + + data: + redis: + host: localhost + port: 6379 + +jwt: + secret-key: dGVzdC1zZWNyZXQta2V5LWZvci11bml0LXRlc3RzLW11c3QtYmUtYXQtbGVhc3QtMjU2LWJpdHMtbG9uZw== + access-token-expiration: 1800000 + refresh-token-expiration: 1209600000 + +social: + apple: + client-id: com.test.app From 91f338d210f78c843826848e224dbc7f12cab467 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 00:47:49 +0900 Subject: [PATCH 14/46] =?UTF-8?q?test(security):=20security=20config=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/TestSecurityConfig.java | 21 +++++++++++++++++++ src/test/resources/application.yaml | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 src/test/java/com/sopt/cherrish/global/config/TestSecurityConfig.java diff --git a/src/test/java/com/sopt/cherrish/global/config/TestSecurityConfig.java b/src/test/java/com/sopt/cherrish/global/config/TestSecurityConfig.java new file mode 100644 index 00000000..be7a1bd5 --- /dev/null +++ b/src/test/java/com/sopt/cherrish/global/config/TestSecurityConfig.java @@ -0,0 +1,21 @@ +package com.sopt.cherrish.global.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +public class TestSecurityConfig { + + @Bean + @Primary + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 2fd2d19f..b3aee2d0 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -7,6 +7,8 @@ spring: exclude: - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration + - org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration datasource: url: jdbc:h2:mem:testdb From a85415a0c253bec9498cc346ebdfddeb5b7aca10 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 00:58:42 +0900 Subject: [PATCH 15/46] =?UTF-8?q?feat(auth):=20authorization=20filter=20te?= =?UTF-8?q?st=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/JwtAuthenticationFilter.java | 1 - .../global/config/SecurityConfig.java | 14 ++++++- .../global/config/TestAuthConfig.java | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java index dcd249f3..a5164e04 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -34,7 +34,6 @@ * 401 응답을 반환합니다.

*/ @Slf4j -@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { diff --git a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java index b61f0c01..66bee91f 100644 --- a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java +++ b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java @@ -14,7 +14,10 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtAuthenticationFilter; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtTokenProvider; +import com.sopt.cherrish.domain.user.domain.repository.UserRepository; import com.sopt.cherrish.global.security.JwtAccessDeniedHandler; import com.sopt.cherrish.global.security.JwtAuthenticationEntryPoint; @@ -25,7 +28,9 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @@ -55,10 +60,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/actuator/health" ).permitAll() .anyRequest().authenticated()) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .build(); } + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtTokenProvider, userRepository, accessTokenBlacklistRepository); + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); diff --git a/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java b/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java new file mode 100644 index 00000000..7f4565cb --- /dev/null +++ b/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java @@ -0,0 +1,38 @@ +package com.sopt.cherrish.global.config; + +import org.mockito.Mockito; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtProperties; +import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtTokenProvider; + +@Configuration +@Profile("test") +public class TestAuthConfig { + + @Bean + @Primary + public JwtProperties jwtProperties() { + JwtProperties properties = new JwtProperties(); + properties.setSecretKey("dGVzdC1zZWNyZXQta2V5LWZvci11bml0LXRlc3RzLW11c3QtYmUtYXQtbGVhc3QtMjU2LWJpdHMtbG9uZw=="); + properties.setAccessTokenExpiration(1800000L); + properties.setRefreshTokenExpiration(1209600000L); + return properties; + } + + @Bean + @Primary + public JwtTokenProvider jwtTokenProvider(JwtProperties jwtProperties) { + return Mockito.mock(JwtTokenProvider.class); + } + + @Bean + @Primary + public AccessTokenBlacklistRepository accessTokenBlacklistRepository() { + return Mockito.mock(AccessTokenBlacklistRepository.class); + } +} From a192ecfea8d519f9505da72c6f99352ad89b75bc Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 01:14:23 +0900 Subject: [PATCH 16/46] =?UTF-8?q?test(auth):=20userFixture=20auth=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/calendar/fixture/CalendarTestFixture.java | 3 +++ .../facade/ChallengeCreationFacadeIntegrationTest.java | 4 ++++ .../ChallengeCustomRoutineFacadeIntegrationTest.java | 6 ++++++ .../core/fixture/ChallengeIntegrationTestFixture.java | 4 ++++ .../com/sopt/cherrish/domain/user/fixture/UserFixture.java | 7 +++++++ .../domain/repository/UserProcedureRepositoryTest.java | 4 ++++ 6 files changed, 28 insertions(+) diff --git a/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java b/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java index 547d258b..5415042d 100644 --- a/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java +++ b/src/test/java/com/sopt/cherrish/domain/calendar/fixture/CalendarTestFixture.java @@ -8,6 +8,7 @@ import com.sopt.cherrish.domain.calendar.presentation.dto.response.CalendarDailyResponseDto; import com.sopt.cherrish.domain.calendar.presentation.dto.response.ProcedureEventDowntimeResponseDto; import com.sopt.cherrish.domain.calendar.presentation.dto.response.ProcedureEventResponseDto; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.procedure.domain.model.Procedure; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.userprocedure.domain.model.UserProcedure; @@ -21,6 +22,8 @@ public static User createMockUser(String name, int age) { return User.builder() .name(name) .age(age) + .socialProvider(SocialProvider.KAKAO) + .socialId("test_social_id") .build(); } diff --git a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java index 73ee6d7d..0d7d3c5e 100644 --- a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java +++ b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCreationFacadeIntegrationTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,6 +26,7 @@ import com.sopt.cherrish.domain.challenge.core.exception.ChallengeException; import com.sopt.cherrish.domain.challenge.core.presentation.dto.request.ChallengeCreateRequestDto; import com.sopt.cherrish.domain.challenge.core.presentation.dto.response.ChallengeCreateResponseDto; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.user.domain.repository.UserRepository; import com.sopt.cherrish.domain.user.exception.UserErrorCode; @@ -65,6 +67,8 @@ private User createTestUser() { return userRepository.save(User.builder() .name("테스트 유저") .age(25) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); } diff --git a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java index 8a568f9d..123e9992 100644 --- a/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java +++ b/src/test/java/com/sopt/cherrish/domain/challenge/core/application/facade/ChallengeCustomRoutineFacadeIntegrationTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -27,6 +28,7 @@ import com.sopt.cherrish.domain.challenge.core.exception.ChallengeException; import com.sopt.cherrish.domain.challenge.core.presentation.dto.request.CustomRoutineAddRequestDto; import com.sopt.cherrish.domain.challenge.core.presentation.dto.response.CustomRoutineAddResponseDto; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.application.service.UserService; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.user.domain.repository.UserRepository; @@ -72,6 +74,8 @@ private User createTestUser() { return userRepository.save(User.builder() .name("테스트 유저") .age(25) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); } @@ -206,6 +210,8 @@ void addCustomRoutineUnauthorizedAccessThrowsException() { User other = userRepository.save(User.builder() .name("다른 유저") .age(30) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); createActiveChallengeWithRoutines(owner, 3); diff --git a/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java b/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java index d03fc7d5..74d4d6b4 100644 --- a/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java +++ b/src/test/java/com/sopt/cherrish/domain/challenge/core/fixture/ChallengeIntegrationTestFixture.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.UUID; import org.springframework.stereotype.Component; @@ -12,6 +13,7 @@ import com.sopt.cherrish.domain.challenge.core.domain.repository.ChallengeRepository; import com.sopt.cherrish.domain.challenge.core.domain.repository.ChallengeRoutineRepository; import com.sopt.cherrish.domain.challenge.core.domain.repository.ChallengeStatisticsRepository; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; import static com.sopt.cherrish.domain.challenge.core.fixture.ChallengeTestConstants.FIXED_START_DATE; @@ -73,6 +75,8 @@ public User createUser(String name, int age) { return userRepository.save(User.builder() .name(name) .age(age) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build()); } diff --git a/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java b/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java index 66796809..3a3a175a 100644 --- a/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java +++ b/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java @@ -3,6 +3,7 @@ import java.lang.reflect.Field; import java.time.LocalDateTime; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.global.entity.BaseTimeEntity; @@ -11,6 +12,8 @@ public class UserFixture { private static final String DEFAULT_NAME = "홍길동"; private static final int DEFAULT_AGE = 25; private static final Long DEFAULT_ID = 1L; + private static final SocialProvider DEFAULT_SOCIAL_PROVIDER = SocialProvider.KAKAO; + private static final String DEFAULT_SOCIAL_ID = "test_social_id_12345"; private UserFixture() { // Utility class @@ -24,6 +27,8 @@ public static User createUser(String name, int age) { User user = User.builder() .name(name) .age(age) + .socialProvider(DEFAULT_SOCIAL_PROVIDER) + .socialId(DEFAULT_SOCIAL_ID) .build(); setField(user, User.class, "id", DEFAULT_ID); setField(user, BaseTimeEntity.class, "createdAt", LocalDateTime.now()); @@ -34,6 +39,8 @@ public static User createUser(String name, int age, LocalDateTime createdAt) { User user = User.builder() .name(name) .age(age) + .socialProvider(DEFAULT_SOCIAL_PROVIDER) + .socialId(DEFAULT_SOCIAL_ID) .build(); setField(user, User.class, "id", DEFAULT_ID); setField(user, BaseTimeEntity.class, "createdAt", createdAt); diff --git a/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java b/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java index 76471fdf..d822d3db 100644 --- a/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java +++ b/src/test/java/com/sopt/cherrish/domain/userprocedure/domain/repository/UserProcedureRepositoryTest.java @@ -6,6 +6,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,6 +17,7 @@ import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.procedure.domain.model.Procedure; import com.sopt.cherrish.domain.user.domain.model.User; import com.sopt.cherrish.domain.userprocedure.domain.model.UserProcedure; @@ -183,6 +185,8 @@ private User createAndPersistUser(String name, int age) { User user = User.builder() .name(name) .age(age) + .socialProvider(SocialProvider.KAKAO) + .socialId(UUID.randomUUID().toString()) .build(); return entityManager.persist(user); } From 4910b2f255549aa704ec026b4c74428785982476 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 17:00:58 +0900 Subject: [PATCH 17/46] =?UTF-8?q?test(redis):=20bean=20=EC=B6=A9=EB=8F=99?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index b3aee2d0..31e3275b 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -3,10 +3,11 @@ spring: profiles: active: test + main: + allow-bean-definition-overriding: true + autoconfigure: exclude: - - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration - - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration From 0d86c120514a3f2b26d67383b9100679cf3c47f7 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:06:38 +0900 Subject: [PATCH 18/46] =?UTF-8?q?faet(docker):=20=EB=A1=9C=EC=BB=AC?= =?UTF-8?q?=EC=9A=A9=20redis=20docker-compose=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.local.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 099001c7..de3beab7 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -16,5 +16,19 @@ services: timeout: 5s retries: 5 + redis: + image: redis:7-alpine + container_name: cherrish-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + volumes: postgres_data: + redis_data: From 9e4be199ee0711d1ac8018b0b2edefb102495df1 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:17:20 +0900 Subject: [PATCH 19/46] =?UTF-8?q?feat(swagger):=20swagger=EC=97=90=20autho?= =?UTF-8?q?rize=20=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/swagger/OpenApiConfigurer.java | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java b/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java index 0cd82135..cea15663 100644 --- a/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java +++ b/src/main/java/com/sopt/cherrish/global/swagger/OpenApiConfigurer.java @@ -2,8 +2,11 @@ import java.util.List; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; public class OpenApiConfigurer { @@ -11,6 +14,7 @@ public class OpenApiConfigurer { private static final String API_TITLE = "Cherrish API"; private static final String API_VERSION = "v1.0.0"; private static final String API_DESCRIPTION = "Cherrish API 문서"; + private static final String SECURITY_SCHEME_NAME = "Bearer Authentication"; private final String baseUrl; @@ -21,7 +25,27 @@ public OpenApiConfigurer(String baseUrl) { public OpenAPI createOpenAPI() { return new OpenAPI() .info(createApiInfo()) - .servers(createServerList()); + .servers(createServerList()) + .components(createComponents()) + .addSecurityItem(createSecurityRequirement()); + } + + private Components createComponents() { + return new Components() + .addSecuritySchemes(SECURITY_SCHEME_NAME, createSecurityScheme()); + } + + private SecurityScheme createSecurityScheme() { + return new SecurityScheme() + .name(SECURITY_SCHEME_NAME) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT Access Token을 입력하세요. (Bearer 접두사 불필요)"); + } + + private SecurityRequirement createSecurityRequirement() { + return new SecurityRequirement().addList(SECURITY_SCHEME_NAME); } private Info createApiInfo() { From 5c7dfb3c0781ef55fd166140dd7f71f4c26a7843 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:25:50 +0900 Subject: [PATCH 20/46] =?UTF-8?q?test:=20profile=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/cherrish/global/config/TestAuthConfig.java | 13 +------------ src/test/resources/application.yaml | 5 ----- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java b/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java index 7f4565cb..e4c76d11 100644 --- a/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java +++ b/src/test/java/com/sopt/cherrish/global/config/TestAuthConfig.java @@ -7,7 +7,6 @@ import org.springframework.context.annotation.Profile; import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; -import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtProperties; import com.sopt.cherrish.domain.auth.infrastructure.jwt.JwtTokenProvider; @Configuration @@ -16,17 +15,7 @@ public class TestAuthConfig { @Bean @Primary - public JwtProperties jwtProperties() { - JwtProperties properties = new JwtProperties(); - properties.setSecretKey("dGVzdC1zZWNyZXQta2V5LWZvci11bml0LXRlc3RzLW11c3QtYmUtYXQtbGVhc3QtMjU2LWJpdHMtbG9uZw=="); - properties.setAccessTokenExpiration(1800000L); - properties.setRefreshTokenExpiration(1209600000L); - return properties; - } - - @Bean - @Primary - public JwtTokenProvider jwtTokenProvider(JwtProperties jwtProperties) { + public JwtTokenProvider jwtTokenProvider() { return Mockito.mock(JwtTokenProvider.class); } diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 31e3275b..ca53fc85 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -37,11 +37,6 @@ spring: openai: api-key: test-dummy-api-key-for-unit-tests - data: - redis: - host: localhost - port: 6379 - jwt: secret-key: dGVzdC1zZWNyZXQta2V5LWZvci11bml0LXRlc3RzLW11c3QtYmUtYXQtbGVhc3QtMjU2LWJpdHMtbG9uZw== access-token-expiration: 1800000 From 12f289477f64b88b9f981b66b6fc1659e410de45 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:31:57 +0900 Subject: [PATCH 21/46] =?UTF-8?q?refactor(auth):=20jwt=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/jwt/JwtTokenProvider.java | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index 355d19d4..736eafc0 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -51,16 +51,7 @@ protected void init() { * @return 생성된 Access Token 문자열 */ public String createAccessToken(Long userId) { - Date now = new Date(); - Date expiration = new Date(now.getTime() + jwtProperties.getAccessTokenExpiration()); - - return Jwts.builder() - .subject(String.valueOf(userId)) - .claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE) - .issuedAt(now) - .expiration(expiration) - .signWith(secretKey) - .compact(); + return createToken(userId, ACCESS_TOKEN_TYPE, jwtProperties.getAccessTokenExpiration()); } /** @@ -70,12 +61,16 @@ public String createAccessToken(Long userId) { * @return 생성된 Refresh Token 문자열 */ public String createRefreshToken(Long userId) { + return createToken(userId, REFRESH_TOKEN_TYPE, jwtProperties.getRefreshTokenExpiration()); + } + + private String createToken(Long userId, String tokenType, long expirationTime) { Date now = new Date(); - Date expiration = new Date(now.getTime() + jwtProperties.getRefreshTokenExpiration()); + Date expiration = new Date(now.getTime() + expirationTime); return Jwts.builder() .subject(String.valueOf(userId)) - .claim(TOKEN_TYPE_CLAIM, REFRESH_TOKEN_TYPE) + .claim(TOKEN_TYPE_CLAIM, tokenType) .issuedAt(now) .expiration(expiration) .signWith(secretKey) From 29618402435db3e705122b41f5e4d94c2106a24d Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:32:36 +0900 Subject: [PATCH 22/46] =?UTF-8?q?refactor(auth):=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/jwt/JwtTokenProvider.java | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index 736eafc0..9a55900d 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -102,17 +102,8 @@ public void validateToken(String token) { } catch (ExpiredJwtException e) { log.debug("Expired JWT token: {}", e.getMessage()); throw new AuthException(AuthErrorCode.TOKEN_EXPIRED); - } catch (UnsupportedJwtException e) { - log.debug("Unsupported JWT token: {}", e.getMessage()); - throw new AuthException(AuthErrorCode.INVALID_TOKEN); - } catch (MalformedJwtException e) { - log.debug("Malformed JWT token: {}", e.getMessage()); - throw new AuthException(AuthErrorCode.INVALID_TOKEN); - } catch (SecurityException e) { - log.debug("Invalid JWT signature: {}", e.getMessage()); - throw new AuthException(AuthErrorCode.INVALID_TOKEN); - } catch (IllegalArgumentException e) { - log.debug("JWT claims string is empty: {}", e.getMessage()); + } catch (UnsupportedJwtException | MalformedJwtException | SecurityException | IllegalArgumentException e) { + log.debug("Invalid JWT token: {}", e.getMessage()); throw new AuthException(AuthErrorCode.INVALID_TOKEN); } } From 22670090e5025b8b83135d3a6f6a10bb0c60bf94 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:34:10 +0900 Subject: [PATCH 23/46] =?UTF-8?q?refactor(auth):=20AuthService=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/service/AuthService.java | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java index eaaec8d0..a22e70cb 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java @@ -77,16 +77,9 @@ public LoginResponseDto login(SocialLoginRequestDto request) { user = existingUser.get(); } - String accessToken = jwtTokenProvider.createAccessToken(user.getId()); - String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + TokenResponseDto tokens = issueTokenPair(user.getId()); - refreshTokenRepository.save( - user.getId(), - refreshToken, - jwtTokenProvider.getRefreshTokenExpiration() - ); - - return new LoginResponseDto(user.getId(), isNewUser, accessToken, refreshToken); + return new LoginResponseDto(user.getId(), isNewUser, tokens.accessToken(), tokens.refreshToken()); } /** @@ -121,16 +114,7 @@ public TokenResponseDto refresh(TokenRefreshRequestDto request) { throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); } - String newAccessToken = jwtTokenProvider.createAccessToken(userId); - String newRefreshToken = jwtTokenProvider.createRefreshToken(userId); - - refreshTokenRepository.save( - userId, - newRefreshToken, - jwtTokenProvider.getRefreshTokenExpiration() - ); - - return new TokenResponseDto(newAccessToken, newRefreshToken); + return issueTokenPair(userId); } /** @@ -152,4 +136,17 @@ public void logout(Long userId, String authorizationHeader) { accessTokenBlacklistRepository.add(accessToken, remainingExpiration); } } + + private TokenResponseDto issueTokenPair(Long userId) { + String accessToken = jwtTokenProvider.createAccessToken(userId); + String refreshToken = jwtTokenProvider.createRefreshToken(userId); + + refreshTokenRepository.save( + userId, + refreshToken, + jwtTokenProvider.getRefreshTokenExpiration() + ); + + return new TokenResponseDto(accessToken, refreshToken); + } } From 9574baada52a852376ca78a4de7164beb947e5ad Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:35:44 +0900 Subject: [PATCH 24/46] =?UTF-8?q?refactor(auth):=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=9D=BC=EC=9B=90?= =?UTF-8?q?=ED=99=94=20(Filter=EC=97=90=EC=84=9C=20Provider=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/JwtAuthenticationFilter.java | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java index a5164e04..3bb21539 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -5,8 +5,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import com.sopt.cherrish.domain.auth.domain.repository.AccessTokenBlacklistRepository; @@ -38,7 +36,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; @@ -50,7 +47,8 @@ protected void doFilterInternal( HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { - String token = resolveToken(request); + String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); + String token = jwtTokenProvider.extractToken(authorizationHeader); if (token != null) { try { @@ -82,12 +80,4 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } - - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { - return bearerToken.substring(BEARER_PREFIX.length()); - } - return null; - } } From 96854a941b4bec2f056a7bd56f66d52d3aeb4674 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 18:38:52 +0900 Subject: [PATCH 25/46] =?UTF-8?q?refactor(auth):=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/JwtAccessDeniedHandler.java | 17 +++---- .../security/JwtAuthenticationEntryPoint.java | 17 +++---- .../security/SecurityErrorResponseWriter.java | 47 +++++++++++++++++++ 3 files changed, 59 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java index c1828533..07e7bfee 100644 --- a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java @@ -1,16 +1,12 @@ package com.sopt.cherrish.global.security; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.databind.ObjectMapper; import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; -import com.sopt.cherrish.global.response.CommonApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -25,7 +21,7 @@ @RequiredArgsConstructor public class JwtAccessDeniedHandler implements AccessDeniedHandler { - private final ObjectMapper objectMapper; + private final SecurityErrorResponseWriter errorResponseWriter; @Override public void handle( @@ -33,11 +29,10 @@ public void handle( HttpServletResponse response, AccessDeniedException accessDeniedException ) throws IOException { - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - - CommonApiResponse errorResponse = CommonApiResponse.fail(AuthErrorCode.ACCESS_DENIED); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + errorResponseWriter.writeErrorResponse( + response, + HttpServletResponse.SC_FORBIDDEN, + AuthErrorCode.ACCESS_DENIED + ); } } diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java index 35e993a5..80884065 100644 --- a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java @@ -1,16 +1,12 @@ package com.sopt.cherrish.global.security; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.databind.ObjectMapper; import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; -import com.sopt.cherrish.global.response.CommonApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -26,7 +22,7 @@ @RequiredArgsConstructor public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - private final ObjectMapper objectMapper; + private final SecurityErrorResponseWriter errorResponseWriter; @Override public void commence( @@ -34,11 +30,10 @@ public void commence( HttpServletResponse response, AuthenticationException authException ) throws IOException { - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - - CommonApiResponse errorResponse = CommonApiResponse.fail(AuthErrorCode.UNAUTHORIZED); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + errorResponseWriter.writeErrorResponse( + response, + HttpServletResponse.SC_UNAUTHORIZED, + AuthErrorCode.UNAUTHORIZED + ); } } diff --git a/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java b/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java new file mode 100644 index 00000000..0b4b1080 --- /dev/null +++ b/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java @@ -0,0 +1,47 @@ +package com.sopt.cherrish.global.security; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sopt.cherrish.domain.auth.exception.AuthErrorCode; +import com.sopt.cherrish.global.response.CommonApiResponse; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * Security 관련 에러 응답을 작성하는 유틸리티 클래스. + * + *

인증/인가 실패 시 일관된 JSON 응답을 생성합니다.

+ */ +@Component +@RequiredArgsConstructor +public class SecurityErrorResponseWriter { + + private final ObjectMapper objectMapper; + + /** + * 에러 응답을 HTTP Response에 작성합니다. + * + * @param response HTTP 응답 객체 + * @param status HTTP 상태 코드 + * @param errorCode 에러 코드 + * @throws IOException 응답 작성 중 예외 발생 시 + */ + public void writeErrorResponse( + HttpServletResponse response, + int status, + AuthErrorCode errorCode + ) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setStatus(status); + + CommonApiResponse errorResponse = CommonApiResponse.fail(errorCode); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} From 3968b5fcc3a0fbf1061066651af4495d08e06da0 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 21:28:04 +0900 Subject: [PATCH 26/46] =?UTF-8?q?refactor(redis):=20redis=20repository=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=EA=B2=BD=EA=B3=A0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/cherrish/global/config/JpaAuditConfig.java | 4 +++- src/main/resources/application-redis.yaml | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java b/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java index 565d3f3f..ba295abb 100644 --- a/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java +++ b/src/main/java/com/sopt/cherrish/global/config/JpaAuditConfig.java @@ -2,8 +2,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -@EnableJpaAuditing @Configuration +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = "com.sopt.cherrish.domain") public class JpaAuditConfig { } diff --git a/src/main/resources/application-redis.yaml b/src/main/resources/application-redis.yaml index 38a1d6f7..bd0b4c8c 100644 --- a/src/main/resources/application-redis.yaml +++ b/src/main/resources/application-redis.yaml @@ -4,3 +4,5 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} + repositories: + enabled: false From 84e277584742015a821a70ccef54b174a83b9868 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 22:39:26 +0900 Subject: [PATCH 27/46] =?UTF-8?q?feat(build.gradle):=20jjwt=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index e9174311..40f2b32d 100644 --- a/build.gradle +++ b/build.gradle @@ -46,9 +46,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' // JWT - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + implementation 'io.jsonwebtoken:jjwt-api:0.12.7' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7' // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' From b74e5c7db5c1596c9cce6c66c79ff180e98c4428 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 22:42:07 +0900 Subject: [PATCH 28/46] =?UTF-8?q?feat(.env.example):=20redis=20password=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 47ebcc1a..e2989857 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,15 @@ -# Database Configuration +프롲# Database Configuration DB_HOST=localhost DB_PORT=5432 DB_NAME=cherrish DB_USERNAME=postgres DB_PASSWORD=postgres +# Redis Configuration (로컬 개발 환경 - 인증 없음) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + # OpenAI Configuration OPENAI_API_KEY=sk-proj-your-api-key-here OPENAI_MODEL=gpt-3.5-turbo From f368d9c5d224d74228529ddbb44c47924438971c Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 22:45:04 +0900 Subject: [PATCH 29/46] =?UTF-8?q?feat(deploy):=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EC=97=90=20jjwt,=20redis?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++++ scripts/deploy-dev.sh | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/.env.example b/.env.example index e2989857..e0221f48 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,12 @@ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= +# JWT Configuration +JWT_SECRET_KEY=your-256-bit-secret-key-here + +# Social Login Configuration +APPLE_CLIENT_ID=your-apple-bundle-id + # OpenAI Configuration OPENAI_API_KEY=sk-proj-your-api-key-here OPENAI_MODEL=gpt-3.5-turbo diff --git a/scripts/deploy-dev.sh b/scripts/deploy-dev.sh index a48c1480..f3c1eaa4 100644 --- a/scripts/deploy-dev.sh +++ b/scripts/deploy-dev.sh @@ -32,6 +32,11 @@ OPENAI_API_KEY=$(aws ssm get-parameter --name "/cherrish/OPENAI_API_KEY" --regio OPENAI_MODEL=$(aws ssm get-parameter --name "/cherrish/OPENAI_MODEL" --region "${AWS_REGION}" --query "Parameter.Value" --output text) SERVER_URL=$(aws ssm get-parameter --name "/cherrish/SERVER_URL" --region "${AWS_REGION}" --query "Parameter.Value" --output text) DISCORD_ERROR_WEBHOOK_URL=$(aws ssm get-parameter --name "/cherrish/DISCORD_ERROR_WEBHOOK_URL" --region "${AWS_REGION}" --with-decryption --query "Parameter.Value" --output text) +REDIS_HOST=$(aws ssm get-parameter --name "/cherrish/REDIS_HOST" --region "${AWS_REGION}" --query "Parameter.Value" --output text) +REDIS_PORT=$(aws ssm get-parameter --name "/cherrish/REDIS_PORT" --region "${AWS_REGION}" --query "Parameter.Value" --output text) +REDIS_PASSWORD=$(aws ssm get-parameter --name "/cherrish/REDIS_PASSWORD" --region "${AWS_REGION}" --with-decryption --query "Parameter.Value" --output text) +JWT_SECRET_KEY=$(aws ssm get-parameter --name "/cherrish/JWT_SECRET_KEY" --region "${AWS_REGION}" --with-decryption --query "Parameter.Value" --output text) +APPLE_CLIENT_ID=$(aws ssm get-parameter --name "/cherrish/APPLE_CLIENT_ID" --region "${AWS_REGION}" --query "Parameter.Value" --output text) # Stop and remove existing container echo "Stopping existing container..." @@ -53,6 +58,11 @@ docker run -d \ -e OPENAI_MODEL="${OPENAI_MODEL}" \ -e SERVER_URL="${SERVER_URL}" \ -e DISCORD_ERROR_WEBHOOK_URL="${DISCORD_ERROR_WEBHOOK_URL}" \ + -e REDIS_HOST="${REDIS_HOST}" \ + -e REDIS_PORT="${REDIS_PORT}" \ + -e REDIS_PASSWORD="${REDIS_PASSWORD}" \ + -e JWT_SECRET_KEY="${JWT_SECRET_KEY}" \ + -e APPLE_CLIENT_ID="${APPLE_CLIENT_ID}" \ "${IMAGE}" # Cleanup old images From 4f536a8e7486301642fbcf637db3a55b9c19c439 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 22:48:11 +0900 Subject: [PATCH 30/46] =?UTF-8?q?feat(auth):=20Refresh=20Token=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=EB=A5=BC=20=EC=83=81=EC=88=98=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=EB=A1=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/service/AuthService.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java index a22e70cb..0ec0ee5a 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/application/service/AuthService.java @@ -1,5 +1,7 @@ package com.sopt.cherrish.domain.auth.application.service; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.Optional; import org.springframework.stereotype.Service; @@ -110,7 +112,7 @@ public TokenResponseDto refresh(TokenRefreshRequestDto request) { String storedToken = refreshTokenRepository.findByUserId(userId) .orElseThrow(() -> new AuthException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND)); - if (!storedToken.equals(refreshToken)) { + if (!constantTimeEquals(storedToken, refreshToken)) { throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); } @@ -149,4 +151,14 @@ private TokenResponseDto issueTokenPair(Long userId) { return new TokenResponseDto(accessToken, refreshToken); } + + private boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + return MessageDigest.isEqual( + a.getBytes(StandardCharsets.UTF_8), + b.getBytes(StandardCharsets.UTF_8) + ); + } } From 6c3d3c0f75a8f207981d66c6166eb959c568a006 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 22:52:57 +0900 Subject: [PATCH 31/46] =?UTF-8?q?refactor(auth):=20Blacklist=20Repository?= =?UTF-8?q?=20HashSet=20Null-Safe=ED=95=98=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/domain/repository/AccessTokenBlacklistRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java index 895f9ba1..185806e1 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java @@ -43,6 +43,6 @@ public void add(String token, long expirationMillis) { */ public boolean isBlacklisted(String token) { String key = KEY_PREFIX + token; - return redisTemplate.hasKey(key); + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } } From 6162d1e147fc28acb0a2d6225758b2482462243c Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 22:55:15 +0900 Subject: [PATCH 32/46] =?UTF-8?q?refactor(auth):=20TTL=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/AccessTokenBlacklistRepository.java | 4 +++- .../domain/repository/RefreshTokenRepository.java | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java index 185806e1..4fc4999f 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/AccessTokenBlacklistRepository.java @@ -38,8 +38,10 @@ public void add(String token, long expirationMillis) { /** * Access Token이 블랙리스트에 있는지 확인합니다. * + *

Redis 연결 문제 등으로 null이 반환될 수 있으므로 null-safe 비교를 수행합니다.

+ * * @param token 확인할 Access Token - * @return 블랙리스트에 있으면 true + * @return 블랙리스트에 있으면 true, 없거나 확인 불가 시 false */ public boolean isBlacklisted(String token) { String key = KEY_PREFIX + token; diff --git a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java index 7765a9b2..dce02682 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/domain/repository/RefreshTokenRepository.java @@ -25,13 +25,17 @@ public class RefreshTokenRepository { /** * Refresh Token을 저장합니다. * - *

기존에 저장된 토큰이 있으면 덮어씁니다.

+ *

기존에 저장된 토큰이 있으면 덮어씁니다. + * 만료 시간이 0 이하인 경우 저장하지 않습니다.

* * @param userId 사용자 ID * @param refreshToken 저장할 Refresh Token * @param expirationMillis 만료 시간 (밀리초) */ public void save(Long userId, String refreshToken, long expirationMillis) { + if (expirationMillis <= 0) { + return; + } String key = KEY_PREFIX + userId; redisTemplate.opsForValue().set(key, refreshToken, expirationMillis, TimeUnit.MILLISECONDS); } @@ -63,11 +67,13 @@ public void deleteByUserId(Long userId) { /** * 사용자의 Refresh Token 존재 여부를 확인합니다. * + *

Redis 연결 문제 등으로 null이 반환될 수 있으므로 null-safe 비교를 수행합니다.

+ * * @param userId 사용자 ID - * @return 토큰이 존재하면 true + * @return 토큰이 존재하면 true, 없거나 확인 불가 시 false */ public boolean existsByUserId(Long userId) { String key = KEY_PREFIX + userId; - return redisTemplate.hasKey(key); + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } } From 5209481837ec05addc8ad631f6ea01b9fdd28615 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 22:58:15 +0900 Subject: [PATCH 33/46] =?UTF-8?q?fix(auth):=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/jwt/JwtAuthenticationFilter.java | 6 ++++++ .../auth/infrastructure/jwt/JwtTokenProvider.java | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java index 3bb21539..08d4508a 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtAuthenticationFilter.java @@ -54,6 +54,12 @@ protected void doFilterInternal( try { jwtTokenProvider.validateToken(token); + if (!jwtTokenProvider.isAccessToken(token)) { + log.debug("Token is not an access token"); + filterChain.doFilter(request, response); + return; + } + if (accessTokenBlacklistRepository.isBlacklisted(token)) { log.debug("Token is blacklisted"); filterChain.doFilter(request, response); diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index 9a55900d..d39efd2d 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -108,6 +108,17 @@ public void validateToken(String token) { } } + /** + * 토큰이 Access Token인지 확인합니다. + * + * @param token JWT 토큰 + * @return Access Token이면 true, Refresh Token이면 false + */ + public boolean isAccessToken(String token) { + Claims claims = parseClaims(token); + return ACCESS_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class)); + } + /** * 토큰이 Refresh Token인지 확인합니다. * From 36447c61089ea218622d60c6a25cc0534b35f3cb Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:00:37 +0900 Subject: [PATCH 34/46] =?UTF-8?q?feat(auth):=20JwtProperties=20validation?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/infrastructure/jwt/JwtProperties.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java index f5a8fe09..cb9abcb6 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtProperties.java @@ -2,17 +2,26 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; import lombok.Getter; import lombok.Setter; @Getter @Setter @Component +@Validated @ConfigurationProperties(prefix = "jwt") public class JwtProperties { + @NotBlank private String secretKey; + + @Positive private long accessTokenExpiration; + + @Positive private long refreshTokenExpiration; } From f7444d043a5bc5d05b9cbb2d8807a2a7cc6472ea Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:03:23 +0900 Subject: [PATCH 35/46] =?UTF-8?q?feat(auth):=20=ED=86=A0=ED=81=B0=20subjec?= =?UTF-8?q?t=20=ED=8C=8C=EC=8B=B1=20=EC=8B=A4=ED=8C=A8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EC=8B=9C=20=EC=9D=91=EB=8B=B5=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/jwt/JwtTokenProvider.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index d39efd2d..adb64f43 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -82,10 +82,16 @@ private String createToken(Long userId, String tokenType, long expirationTime) { * * @param token JWT 토큰 * @return 사용자 ID + * @throws AuthException subject가 유효한 사용자 ID가 아닌 경우 */ public Long getUserId(String token) { - Claims claims = parseClaims(token); - return Long.parseLong(claims.getSubject()); + try { + Claims claims = parseClaims(token); + return Long.parseLong(claims.getSubject()); + } catch (NumberFormatException | NullPointerException e) { + log.debug("Invalid user ID in token: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } } /** From c996fa16dc3dff0665c2febdf34fd70824b17d8d Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:09:07 +0900 Subject: [PATCH 36/46] =?UTF-8?q?feat(auth):=20SceurityConfig=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/cherrish/global/config/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java index 66bee91f..ce612861 100644 --- a/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java +++ b/src/main/java/com/sopt/cherrish/global/config/SecurityConfig.java @@ -53,7 +53,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .accessDeniedHandler(jwtAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/api/auth/**", + "/api/auth/login", + "/api/auth/refresh", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", From f16e4e6e64ddfa0137b315596402e9f2c9053313 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:13:45 +0900 Subject: [PATCH 37/46] =?UTF-8?q?fix(auth):=20SecurityErrorResponseWriter?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C/=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=9C=84?= =?UTF-8?q?=ED=97=98=20=EC=A0=9C=EA=B1=B0=20=EA=B6=8C=EC=9E=A5=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cherrish/global/security/JwtAccessDeniedHandler.java | 6 +----- .../global/security/JwtAuthenticationEntryPoint.java | 6 +----- .../global/security/SecurityErrorResponseWriter.java | 6 +++--- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java index 07e7bfee..f5c8a529 100644 --- a/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAccessDeniedHandler.java @@ -29,10 +29,6 @@ public void handle( HttpServletResponse response, AccessDeniedException accessDeniedException ) throws IOException { - errorResponseWriter.writeErrorResponse( - response, - HttpServletResponse.SC_FORBIDDEN, - AuthErrorCode.ACCESS_DENIED - ); + errorResponseWriter.writeErrorResponse(response, AuthErrorCode.ACCESS_DENIED); } } diff --git a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java index 80884065..5eb0a098 100644 --- a/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/sopt/cherrish/global/security/JwtAuthenticationEntryPoint.java @@ -30,10 +30,6 @@ public void commence( HttpServletResponse response, AuthenticationException authException ) throws IOException { - errorResponseWriter.writeErrorResponse( - response, - HttpServletResponse.SC_UNAUTHORIZED, - AuthErrorCode.UNAUTHORIZED - ); + errorResponseWriter.writeErrorResponse(response, AuthErrorCode.UNAUTHORIZED); } } diff --git a/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java b/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java index 0b4b1080..a9d2ad43 100644 --- a/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java +++ b/src/main/java/com/sopt/cherrish/global/security/SecurityErrorResponseWriter.java @@ -27,19 +27,19 @@ public class SecurityErrorResponseWriter { /** * 에러 응답을 HTTP Response에 작성합니다. * + *

HTTP 상태 코드는 AuthErrorCode에서 가져옵니다.

+ * * @param response HTTP 응답 객체 - * @param status HTTP 상태 코드 * @param errorCode 에러 코드 * @throws IOException 응답 작성 중 예외 발생 시 */ public void writeErrorResponse( HttpServletResponse response, - int status, AuthErrorCode errorCode ) throws IOException { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - response.setStatus(status); + response.setStatus(errorCode.getStatus()); CommonApiResponse errorResponse = CommonApiResponse.fail(errorCode); response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); From 5127829b566b9990c1202276fdf8b8e2268ba367 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:17:03 +0900 Subject: [PATCH 38/46] =?UTF-8?q?fix(auth):=20AuthController=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20EndPoint=20swagger=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/cherrish/domain/auth/presentation/AuthController.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java index e7edc92c..445c1478 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/presentation/AuthController.java @@ -21,6 +21,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -37,6 +38,7 @@ public class AuthController { summary = "소셜 로그인", description = "카카오 또는 애플 소셜 토큰으로 로그인합니다. 신규 사용자는 isNewUser: true를 반환합니다." ) + @SecurityRequirements @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) @PostMapping("/login") public CommonApiResponse login( @@ -50,6 +52,7 @@ public CommonApiResponse login( summary = "토큰 재발급", description = "Refresh Token으로 새로운 Access Token과 Refresh Token을 발급받습니다." ) + @SecurityRequirements @ApiExceptions({AuthErrorCode.class, ErrorCode.class}) @PostMapping("/refresh") public CommonApiResponse refresh( From bd3e6f8c6514da4ece7949cb7262842277b1ddd5 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:20:08 +0900 Subject: [PATCH 39/46] =?UTF-8?q?fix(auth):=20jwt=20default=20key=20value?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-auth.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-auth.yaml b/src/main/resources/application-auth.yaml index 3b0a14d7..05977cb2 100644 --- a/src/main/resources/application-auth.yaml +++ b/src/main/resources/application-auth.yaml @@ -1,5 +1,5 @@ jwt: - secret-key: ${JWT_SECRET_KEY:defaultSecretKeyForDevelopmentOnlyPleaseChangeInProduction1234567890} + secret-key: ${JWT_SECRET_KEY} access-token-expiration: 1800000 # 30분 (밀리초) refresh-token-expiration: 1209600000 # 14일 (밀리초) From 11eb04d13325b706c88512a11c6c0dd9fdecab54 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:22:39 +0900 Subject: [PATCH 40/46] =?UTF-8?q?fix(test):=20UserFixture=20id=20=EC=9B=90?= =?UTF-8?q?=EC=9E=90=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/fixture/UserFixture.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java b/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java index 3a3a175a..c2b67112 100644 --- a/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java +++ b/src/test/java/com/sopt/cherrish/domain/user/fixture/UserFixture.java @@ -2,6 +2,7 @@ import java.lang.reflect.Field; import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicLong; import com.sopt.cherrish.domain.auth.domain.model.SocialProvider; import com.sopt.cherrish.domain.user.domain.model.User; @@ -11,9 +12,10 @@ public class UserFixture { private static final String DEFAULT_NAME = "홍길동"; private static final int DEFAULT_AGE = 25; - private static final Long DEFAULT_ID = 1L; private static final SocialProvider DEFAULT_SOCIAL_PROVIDER = SocialProvider.KAKAO; - private static final String DEFAULT_SOCIAL_ID = "test_social_id_12345"; + + private static final AtomicLong ID_COUNTER = new AtomicLong(1); + private static final AtomicLong SOCIAL_ID_COUNTER = new AtomicLong(1); private UserFixture() { // Utility class @@ -28,9 +30,9 @@ public static User createUser(String name, int age) { .name(name) .age(age) .socialProvider(DEFAULT_SOCIAL_PROVIDER) - .socialId(DEFAULT_SOCIAL_ID) + .socialId(generateSocialId()) .build(); - setField(user, User.class, "id", DEFAULT_ID); + setField(user, User.class, "id", ID_COUNTER.getAndIncrement()); setField(user, BaseTimeEntity.class, "createdAt", LocalDateTime.now()); return user; } @@ -40,13 +42,17 @@ public static User createUser(String name, int age, LocalDateTime createdAt) { .name(name) .age(age) .socialProvider(DEFAULT_SOCIAL_PROVIDER) - .socialId(DEFAULT_SOCIAL_ID) + .socialId(generateSocialId()) .build(); - setField(user, User.class, "id", DEFAULT_ID); + setField(user, User.class, "id", ID_COUNTER.getAndIncrement()); setField(user, BaseTimeEntity.class, "createdAt", createdAt); return user; } + private static String generateSocialId() { + return "test_social_id_" + SOCIAL_ID_COUNTER.getAndIncrement(); + } + private static void setField(Object target, Class clazz, String fieldName, Object value) { try { Field field = clazz.getDeclaredField(fieldName); From 0a477288dbb204fdb235bdfe46bf784f1c2d30c0 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:48:05 +0900 Subject: [PATCH 41/46] =?UTF-8?q?feat(.env.example):=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=9C=20=EB=AC=B8=EC=9E=90=20=EB=93=A4=EC=96=B4=EA=B0=84?= =?UTF-8?q?=EA=B1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e0221f48..eb2b0902 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -프롲# Database Configuration +# Database Configuration DB_HOST=localhost DB_PORT=5432 DB_NAME=cherrish From 14ca88a6c2296f0067ec92ceee4ccccfd97796fd Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:50:28 +0900 Subject: [PATCH 42/46] =?UTF-8?q?feat(auth):=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20email=20unique=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/cherrish/domain/user/domain/model/User.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java index 6c9c3d06..e0e27d5f 100644 --- a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java +++ b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java @@ -39,7 +39,6 @@ public class User extends BaseTimeEntity { @Column(nullable = false, unique = true) private String socialId; - @Column(unique = true) private String email; @Builder From 5d87aecbc4e9ee7562364f019d43095f62dd99b4 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:54:06 +0900 Subject: [PATCH 43/46] =?UTF-8?q?feat(auth):=20jwt=20=ED=82=A4=EA=B0=92=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/jwt/JwtTokenProvider.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index adb64f43..bf5ea676 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -34,13 +34,28 @@ public class JwtTokenProvider { private static final String TOKEN_TYPE_CLAIM = "type"; private static final String ACCESS_TOKEN_TYPE = "access"; private static final String REFRESH_TOKEN_TYPE = "refresh"; + private static final int MIN_SECRET_KEY_BYTES = 32; private final JwtProperties jwtProperties; private SecretKey secretKey; @PostConstruct protected void init() { - byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey()); + byte[] keyBytes; + try { + keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey()); + } catch (Exception e) { + throw new IllegalStateException( + "JWT secret key is not valid Base64 encoded. Please provide a valid Base64 encoded key.", e); + } + + if (keyBytes.length < MIN_SECRET_KEY_BYTES) { + throw new IllegalStateException(String.format( + "JWT secret key must be at least %d bytes (256 bits) for HMAC-SHA. Current key is %d bytes. " + + "Generate a new key with: openssl rand -base64 32", + MIN_SECRET_KEY_BYTES, keyBytes.length)); + } + this.secretKey = Keys.hmacShaKeyFor(keyBytes); } From c8855bedbee53d33c4c8294dfd81e38f2819a3b5 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 26 Jan 2026 23:56:45 +0900 Subject: [PATCH 44/46] =?UTF-8?q?feat(auth):=20jwt=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=84=B8=EB=B6=84?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/infrastructure/jwt/JwtTokenProvider.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java index bf5ea676..293a2a17 100644 --- a/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java +++ b/src/main/java/com/sopt/cherrish/domain/auth/infrastructure/jwt/JwtTokenProvider.java @@ -164,7 +164,7 @@ public long getRefreshTokenExpiration() { * 토큰의 남은 만료 시간(밀리초)을 반환합니다. * * @param token JWT 토큰 - * @return 남은 만료 시간 (밀리초), 이미 만료된 경우 0 + * @return 남은 만료 시간 (밀리초), 이미 만료되었거나 유효하지 않은 경우 0 */ public long getRemainingExpiration(String token) { try { @@ -172,7 +172,12 @@ public long getRemainingExpiration(String token) { Date expiration = claims.getExpiration(); long remaining = expiration.getTime() - System.currentTimeMillis(); return Math.max(0, remaining); + } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException + | SecurityException | IllegalArgumentException e) { + log.debug("Expected JWT exception in getRemainingExpiration: {}", e.getMessage()); + return 0; } catch (Exception e) { + log.warn("Unexpected exception in getRemainingExpiration: {}", e.getMessage(), e); return 0; } } From be2fb57c209d59650925b53a0474751886f8d0b8 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Tue, 27 Jan 2026 00:10:39 +0900 Subject: [PATCH 45/46] =?UTF-8?q?feat(env.example):=20jwt=20=EA=B0=92=20?= =?UTF-8?q?=EC=98=88=EC=8B=9C=20=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index eb2b0902..250156da 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,8 @@ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= -# JWT Configuration -JWT_SECRET_KEY=your-256-bit-secret-key-here +# JWT Configuration (Base64-encoded 32+ bytes; e.g., openssl rand -base64 32) +JWT_SECRET_KEY=MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= # Social Login Configuration APPLE_CLIENT_ID=your-apple-bundle-id From 8676edaf86d3c03dc068b9b87c6db978779a1ba9 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Tue, 27 Jan 2026 02:31:20 +0900 Subject: [PATCH 46/46] =?UTF-8?q?feat(User):=20social=5Fprovider,=20social?= =?UTF-8?q?=5Fid=20=EB=B3=B5=ED=95=A9=ED=82=A4=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sopt/cherrish/domain/user/domain/model/User.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java index e0e27d5f..70479608 100644 --- a/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java +++ b/src/main/java/com/sopt/cherrish/domain/user/domain/model/User.java @@ -11,13 +11,17 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "users") +@Table( + name = "users", + uniqueConstraints = @UniqueConstraint(columnNames = {"social_provider", "social_id"}) +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseTimeEntity { @@ -36,7 +40,7 @@ public class User extends BaseTimeEntity { @Column(nullable = false, length = 10) private SocialProvider socialProvider; - @Column(nullable = false, unique = true) + @Column(nullable = false) private String socialId; private String email;