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 extends GrantedAuthority> authorities;
+
+ public static UserPrincipal from(User user) {
+ return new UserPrincipal(
+ user.getId(),
+ user.getName(),
+ Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
+ );
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> 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 extends GrantedAuthority> 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;