From bfd6ce7b1e2b1cc496e431b0ae2cca9e3ea2e1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:36:35 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20JWT=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/global/auth/JwtProperties.java | 11 ++ .../agit/konect/global/auth/JwtProvider.java | 132 ++++++++++++++++++ .../interceptor/LoginCheckInterceptor.java | 28 ++-- .../resolver/LoginUserArgumentResolver.java | 10 +- 4 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 src/main/java/gg/agit/konect/global/auth/JwtProperties.java create mode 100644 src/main/java/gg/agit/konect/global/auth/JwtProvider.java diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProperties.java b/src/main/java/gg/agit/konect/global/auth/JwtProperties.java new file mode 100644 index 00000000..4abd326a --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/JwtProperties.java @@ -0,0 +1,11 @@ +package gg.agit.konect.global.auth; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.jwt") +public record JwtProperties( + String secret, + String issuer, + long accessTokenTtlSeconds +) { +} diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java new file mode 100644 index 00000000..e2a9fadb --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java @@ -0,0 +1,132 @@ +package gg.agit.konect.global.auth; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private static final int MIN_HS256_SECRET_BYTES = 32; + private static final String CLAIM_USER_ID = "id"; + + private final JwtProperties properties; + + public String createToken(Integer userId) { + if (userId == null) { + throw new IllegalArgumentException("userId is required"); + } + + Instant now = Instant.now(); + Instant expiresAt = now.plus(accessTtl()); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(resolveIssuer()) + .issueTime(Date.from(now)) + .expirationTime(Date.from(expiresAt)) + .jwtID(UUID.randomUUID().toString()) + .claim(CLAIM_USER_ID, userId) + .build(); + + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + + try { + jwt.sign(new MACSigner(resolveSecretBytes())); + } catch (JOSEException e) { + throw new IllegalStateException("Failed to sign access token.", e); + } + + return jwt.serialize(); + } + + public Integer getUserId(String token) { + if (!StringUtils.hasText(token)) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + SignedJWT jwt; + try { + jwt = SignedJWT.parse(token); + } catch (Exception e) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + try { + if (!jwt.verify(new MACVerifier(resolveSecretBytes()))) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + } catch (JOSEException e) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + JWTClaimsSet claims; + try { + claims = jwt.getJWTClaimsSet(); + } catch (Exception e) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + if (!resolveIssuer().equals(claims.getIssuer())) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + Date exp = claims.getExpirationTime(); + if (exp == null || Instant.now().isAfter(exp.toInstant())) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + Object id = claims.getClaim(CLAIM_USER_ID); + if (!(id instanceof Number number)) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + return number.intValue(); + } + + public Duration accessTtl() { + long seconds = properties.accessTokenTtlSeconds(); + if (seconds <= 0) { + throw new IllegalStateException("app.jwt.access-token-ttl-seconds must be positive"); + } + return Duration.ofSeconds(seconds); + } + + private String resolveIssuer() { + String issuer = properties.issuer(); + if (!StringUtils.hasText(issuer)) { + throw new IllegalStateException("app.jwt.issuer is required"); + } + return issuer; + } + + private byte[] resolveSecretBytes() { + String secret = properties.secret(); + if (!StringUtils.hasText(secret)) { + throw new IllegalStateException("app.jwt.secret is required"); + } + + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < MIN_HS256_SECRET_BYTES) { + throw new IllegalStateException("app.jwt.secret must be at least 32 bytes"); + } + return bytes; + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java b/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java index 09a02140..928fbd93 100644 --- a/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java +++ b/src/main/java/gg/agit/konect/global/auth/interceptor/LoginCheckInterceptor.java @@ -6,18 +6,23 @@ import org.springframework.web.servlet.HandlerInterceptor; import gg.agit.konect.global.auth.annotation.PublicApi; -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.auth.JwtProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; @Component +@RequiredArgsConstructor public class LoginCheckInterceptor implements HandlerInterceptor { public static final String AUTHENTICATED_USER_ID_ATTRIBUTE = "authenticatedUserId"; public static final String PUBLIC_ENDPOINT_ATTRIBUTE = "publicEndpoint"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtProvider jwtProvider; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (HttpMethod.OPTIONS.matches(request.getMethod())) { @@ -33,13 +38,8 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } - HttpSession session = request.getSession(false); - Object userId = session == null ? null : session.getAttribute("userId"); - - if (!(userId instanceof Integer)) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); - } - + String accessToken = resolveBearerToken(request); + Integer userId = jwtProvider.getUserId(accessToken); request.setAttribute(AUTHENTICATED_USER_ID_ATTRIBUTE, userId); return true; @@ -49,4 +49,12 @@ private boolean isPublicEndpoint(HandlerMethod handlerMethod) { return handlerMethod.hasMethodAnnotation(PublicApi.class) || handlerMethod.getBeanType().isAnnotationPresent(PublicApi.class); } + + private String resolveBearerToken(HttpServletRequest request) { + String authorization = request.getHeader(AUTHORIZATION_HEADER); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + return authorization.substring(BEARER_PREFIX.length()); + } } diff --git a/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java b/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java index 639e4e33..b39bc1b2 100644 --- a/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java +++ b/src/main/java/gg/agit/konect/global/auth/resolver/LoginUserArgumentResolver.java @@ -8,10 +8,10 @@ import org.springframework.web.method.support.ModelAndViewContainer; import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.global.auth.interceptor.LoginCheckInterceptor; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; @Component public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { @@ -33,14 +33,8 @@ public Object resolveArgument( WebDataBinderFactory binderFactory ) { HttpServletRequest request = (HttpServletRequest)webRequest.getNativeRequest(); - HttpSession session = request.getSession(false); - - if (session == null) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); - } - - Object userId = session.getAttribute("userId"); + Object userId = request.getAttribute(LoginCheckInterceptor.AUTHENTICATED_USER_ID_ATTRIBUTE); if (!(userId instanceof Integer id)) { throw CustomException.of(ApiResponseCode.INVALID_SESSION); } From 9a337aed084799053332ad5ccd572882898a0cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:37:01 +0900 Subject: [PATCH 02/15] =?UTF-8?q?chore:=20CORS=EC=97=90=EC=84=9C=20Authori?= =?UTF-8?q?zation=20=ED=97=A4=EB=8D=94=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gg/agit/konect/global/config/WebConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/gg/agit/konect/global/config/WebConfig.java b/src/main/java/gg/agit/konect/global/config/WebConfig.java index 7ceea7e4..71b3b382 100644 --- a/src/main/java/gg/agit/konect/global/config/WebConfig.java +++ b/src/main/java/gg/agit/konect/global/config/WebConfig.java @@ -31,6 +31,7 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(corsProperties.allowedOrigins().toArray(new String[0])) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") + .exposedHeaders("Authorization") .allowCredentials(true) .maxAge(CORS_PREFLIGHT_MAX_AGE_SECONDS); } From a2a17e4b15e920712d623235cf440d1a06b3795d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:39:23 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EB=A1=9C=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B0=8F=20Redis=20=EC=A0=80=EC=9E=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/RefreshTokenService.java | 171 ++++++++++++++++++ .../global/auth/token/AuthCookieService.java | 71 ++++++++ 2 files changed, 242 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java create mode 100644 src/main/java/gg/agit/konect/global/auth/token/AuthCookieService.java diff --git a/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java new file mode 100644 index 00000000..49dfb346 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java @@ -0,0 +1,171 @@ +package gg.agit.konect.domain.user.service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; + +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private static final int TOKEN_BYTES = 32; + + private static final String ACTIVE_PREFIX = "auth:refresh:active:"; + private static final String REVOKED_PREFIX = "auth:refresh:revoked:"; + private static final String USER_SET_PREFIX = "auth:refresh:user:"; + + private static final DefaultRedisScript GET_DEL_SCRIPT = + new DefaultRedisScript<>( + "local v = redis.call('GET', KEYS[1]); " + + "if v then redis.call('DEL', KEYS[1]); end; " + + "return v;", + String.class + ); + + private final SecureRandom secureRandom = new SecureRandom(); + + private final StringRedisTemplate redis; + + @Value("${app.auth.refresh-token-ttl-seconds:2592000}") + private long refreshTokenTtlSeconds; + + public Duration refreshTtl() { + if (refreshTokenTtlSeconds <= 0) { + throw new IllegalStateException("app.auth.refresh-token-ttl-seconds must be positive"); + } + return Duration.ofSeconds(refreshTokenTtlSeconds); + } + + public String issue(Integer userId) { + if (userId == null) { + throw new IllegalArgumentException("userId is required"); + } + + String token = generateToken(); + Duration ttl = refreshTtl(); + + redis.opsForValue().set(activeKey(token), userId.toString(), ttl); + redis.opsForSet().add(userSetKey(userId), token); + redis.expire(userSetKey(userId), ttl); + + return token; + } + + public Rotated rotate(String refreshToken) { + if (!StringUtils.hasText(refreshToken)) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + Integer userId = consumeActive(refreshToken); + if (userId == null) { + Integer revokedUserId = findRevokedUserId(refreshToken); + if (revokedUserId != null) { + revokeAll(revokedUserId); + } + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String newToken = issue(userId); + return new Rotated(userId, newToken); + } + + public void revoke(String refreshToken) { + if (!StringUtils.hasText(refreshToken)) { + return; + } + + String value = redis.execute(GET_DEL_SCRIPT, List.of(activeKey(refreshToken))); + Integer userId = parseUserId(value); + if (userId == null) { + return; + } + + Duration ttl = refreshTtl(); + redis.opsForValue().set(revokedKey(refreshToken), userId.toString(), ttl); + redis.opsForSet().remove(userSetKey(userId), refreshToken); + } + + public void revokeAll(Integer userId) { + if (userId == null) { + return; + } + + String setKey = userSetKey(userId); + var tokens = redis.opsForSet().members(setKey); + + if (tokens == null || tokens.isEmpty()) { + redis.delete(setKey); + return; + } + + Duration ttl = refreshTtl(); + for (String token : tokens) { + redis.delete(activeKey(token)); + redis.opsForValue().set(revokedKey(token), userId.toString(), ttl); + } + + redis.delete(setKey); + } + + private Integer consumeActive(String token) { + String value = redis.execute(GET_DEL_SCRIPT, List.of(activeKey(token))); + Integer userId = parseUserId(value); + if (userId == null) { + return null; + } + + Duration ttl = refreshTtl(); + redis.opsForValue().set(revokedKey(token), userId.toString(), ttl); + redis.opsForSet().remove(userSetKey(userId), token); + return userId; + } + + private Integer findRevokedUserId(String token) { + String value = redis.opsForValue().get(revokedKey(token)); + return parseUserId(value); + } + + private String activeKey(String token) { + return ACTIVE_PREFIX + token; + } + + private String revokedKey(String token) { + return REVOKED_PREFIX + token; + } + + private String userSetKey(Integer userId) { + return USER_SET_PREFIX + userId; + } + + private String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private Integer parseUserId(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + } + + public record Rotated(Integer userId, String refreshToken) { + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/token/AuthCookieService.java b/src/main/java/gg/agit/konect/global/auth/token/AuthCookieService.java new file mode 100644 index 00000000..44d41deb --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/token/AuthCookieService.java @@ -0,0 +1,71 @@ +package gg.agit.konect.global.auth.token; + +import java.time.Duration; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthCookieService { + + public static final String REFRESH_TOKEN_COOKIE = "refresh_token"; + public static final String SIGNUP_TOKEN_COOKIE = "signup_token"; + + private static final String COOKIE_PATH = "/"; + + public void setRefreshToken(HttpServletRequest request, HttpServletResponse response, String token, Duration ttl) { + ResponseCookie cookie = baseCookie(request, REFRESH_TOKEN_COOKIE, token) + .maxAge(ttl) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void clearRefreshToken(HttpServletRequest request, HttpServletResponse response) { + ResponseCookie cookie = baseCookie(request, REFRESH_TOKEN_COOKIE, "") + .maxAge(Duration.ZERO) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void setSignupToken(HttpServletRequest request, HttpServletResponse response, String token, Duration ttl) { + ResponseCookie cookie = baseCookie(request, SIGNUP_TOKEN_COOKIE, token) + .maxAge(ttl) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void clearSignupToken(HttpServletRequest request, HttpServletResponse response) { + ResponseCookie cookie = baseCookie(request, SIGNUP_TOKEN_COOKIE, "") + .maxAge(Duration.ZERO) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + private ResponseCookie.ResponseCookieBuilder baseCookie(HttpServletRequest request, String name, String value) { + ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(isSecureRequest(request)) + .path(COOKIE_PATH); + + return builder; + } + + private boolean isSecureRequest(HttpServletRequest request) { + if (request.isSecure()) { + return true; + } + + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + + return "https".equalsIgnoreCase(forwardedProto); + } +} From c8f80884c2f3b1ae6ab9fb858b9639c88a5dcb7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:39:38 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=86=A0=ED=81=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=B0=8F=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 --- .../user/service/SignupTokenService.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java diff --git a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java new file mode 100644 index 00000000..d87a60d7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java @@ -0,0 +1,116 @@ +package gg.agit.konect.domain.user.service; + +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SignupTokenService { + + private static final int TOKEN_BYTES = 32; + private static final String KEY_PREFIX = "auth:signup:"; + private static final String DELIMITER = "|"; + private static final int EXPECTED_PARTS = 3; + + private static final DefaultRedisScript GET_DEL_SCRIPT = + new DefaultRedisScript<>( + "local v = redis.call('GET', KEYS[1]); " + + "if v then redis.call('DEL', KEYS[1]); end; " + + "return v;", + String.class + ); + + private final SecureRandom secureRandom = new SecureRandom(); + + private final StringRedisTemplate redis; + + @Value("${app.auth.signup-token-ttl-seconds:600}") + private long signupTokenTtlSeconds; + + public Duration signupTtl() { + if (signupTokenTtlSeconds <= 0) { + throw new IllegalStateException("app.auth.signup-token-ttl-seconds must be positive"); + } + return Duration.ofSeconds(signupTokenTtlSeconds); + } + + public String issue(String email, Provider provider, String providerId) { + if (!StringUtils.hasText(email) || provider == null) { + throw new IllegalArgumentException("email and provider are required"); + } + + String token = generateToken(); + redis.opsForValue().set(key(token), serialize(new SignupClaims(email, provider, providerId)), signupTtl()); + return token; + } + + public SignupClaims consumeOrThrow(String token) { + if (!StringUtils.hasText(token)) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + String value = redis.execute(GET_DEL_SCRIPT, List.of(key(token))); + SignupClaims claims = deserialize(value); + if (claims == null) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + return claims; + } + + private String key(String token) { + return KEY_PREFIX + token; + } + + private String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String serialize(SignupClaims claims) { + String safeProviderId = claims.providerId() == null ? "" : claims.providerId(); + return claims.email() + DELIMITER + claims.provider().name() + DELIMITER + safeProviderId; + } + + private SignupClaims deserialize(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + + String[] parts = value.split("\\|", -1); + if (parts.length != EXPECTED_PARTS) { + return null; + } + + String email = parts[0]; + String provider = parts[1]; + String providerId = parts[2]; + + if (!StringUtils.hasText(email) || !StringUtils.hasText(provider)) { + return null; + } + + try { + Provider p = Provider.valueOf(provider); + return new SignupClaims(email, p, StringUtils.hasText(providerId) ? providerId : null); + } catch (Exception e) { + return null; + } + } + + public record SignupClaims(String email, Provider provider, String providerId) { + } +} From 70bf75f2d9c25f7a6987b790295cfa68c23ec84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:40:02 +0900 Subject: [PATCH 05/15] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=20API=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=98=EC=A1=B4=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/UserApi.java | 19 +++-- .../user/controller/UserController.java | 82 +++++++++++++------ 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java index 978d67f1..c6e97f09 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java @@ -16,7 +16,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @Tag(name = "(Normal) User: 유저", description = "유저 API") @@ -38,9 +38,9 @@ public interface UserApi { @PostMapping("/signup") @PublicApi ResponseEntity signup( - HttpServletRequest httpServletRequest, - HttpSession session, - @RequestBody @Valid SignupRequest request + HttpServletRequest request, + HttpServletResponse response, + @RequestBody @Valid SignupRequest signupRequest ); @Operation(summary = "로그인한 사용자의 정보를 조회한다.") @@ -50,16 +50,21 @@ ResponseEntity signup( @Operation(summary = "로그인한 사용자의 정보를 수정한다.") @PutMapping("/me") ResponseEntity updateMyInfo( - HttpSession session, + @UserId Integer userId, @RequestBody @Valid UserUpdateRequest request ); @Operation(summary = "로그아웃한다.") @PostMapping("/logout") @PublicApi - ResponseEntity logout(HttpServletRequest request); + ResponseEntity logout(HttpServletRequest request, HttpServletResponse response); + + @Operation(summary = "리프레시 토큰으로 액세스 토큰을 재발급한다.") + @PostMapping("/refresh") + @PublicApi + ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response); @Operation(summary = "회원탈퇴를 한다.") @DeleteMapping("/withdraw") - ResponseEntity withdraw(HttpServletRequest request, @UserId Integer userId); + ResponseEntity withdraw(HttpServletRequest request, HttpServletResponse response, @UserId Integer userId); } diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java index f0f341d1..cc17f3eb 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java @@ -4,17 +4,21 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.WebUtils; import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.domain.user.dto.UserUpdateRequest; -import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.service.UserService; +import gg.agit.konect.global.auth.annotation.PublicApi; import gg.agit.konect.global.auth.annotation.UserId; -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.auth.JwtProvider; +import gg.agit.konect.global.auth.token.AuthCookieService; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.domain.user.service.SignupTokenService; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -24,27 +28,30 @@ public class UserController implements UserApi { private final UserService userService; + private final SignupTokenService signupTokenService; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final AuthCookieService authCookieService; @Override + @PublicApi public ResponseEntity signup( - HttpServletRequest httpServletRequest, - HttpSession session, - @RequestBody @Valid SignupRequest request + HttpServletRequest request, + HttpServletResponse response, + @RequestBody @Valid SignupRequest signupRequest ) { - String email = (String)session.getAttribute("email"); - Provider provider = (Provider)session.getAttribute("provider"); - String providerId = (String)session.getAttribute("providerId"); + String signupToken = getCookieValue(request, AuthCookieService.SIGNUP_TOKEN_COOKIE); + SignupTokenService.SignupClaims claims = signupTokenService.consumeOrThrow(signupToken); - if (email == null || provider == null) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); - } + Integer userId = userService.signup(claims.email(), claims.providerId(), claims.provider(), signupRequest); - Integer userId = userService.signup(email, providerId, provider, request); + authCookieService.clearSignupToken(request, response); - session.invalidate(); + String refreshToken = refreshTokenService.issue(userId); + authCookieService.setRefreshToken(request, response, refreshToken, refreshTokenService.refreshTtl()); - HttpSession newSession = httpServletRequest.getSession(true); - newSession.setAttribute("userId", userId); + String accessToken = jwtProvider.createToken(userId); + response.setHeader("Authorization", "Bearer " + accessToken); return ResponseEntity.ok().build(); } @@ -58,32 +65,53 @@ public ResponseEntity getMyInfo(@UserId Integer userId) { @Override public ResponseEntity updateMyInfo( - HttpSession session, + @UserId Integer userId, @RequestBody @Valid UserUpdateRequest request ) { - Integer userId = (Integer)session.getAttribute("userId"); - userService.updateUserInfo(userId, request); return ResponseEntity.ok().build(); } @Override - public ResponseEntity logout(HttpServletRequest request) { - HttpSession session = request.getSession(false); + @PublicApi + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE); + refreshTokenService.revoke(refreshToken); + + authCookieService.clearRefreshToken(request, response); + authCookieService.clearSignupToken(request, response); + + return ResponseEntity.ok().build(); + } + + @Override + @PublicApi + public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE); + RefreshTokenService.Rotated rotated = refreshTokenService.rotate(refreshToken); - if (session != null) { - session.invalidate(); - } + String accessToken = jwtProvider.createToken(rotated.userId()); + response.setHeader("Authorization", "Bearer " + accessToken); + authCookieService.setRefreshToken(request, response, rotated.refreshToken(), refreshTokenService.refreshTtl()); return ResponseEntity.ok().build(); } @Override - public ResponseEntity withdraw(HttpServletRequest request, @UserId Integer userId) { + public ResponseEntity withdraw( + HttpServletRequest request, + HttpServletResponse response, + @UserId Integer userId + ) { userService.deleteUser(userId); - logout(request); + logout(request, response); return ResponseEntity.noContent().build(); } + + private String getCookieValue(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + return cookie == null ? null : cookie.getValue(); + } } From bbf3e03e0e9ee0c5466179569932a1d3cf31db14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:40:25 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20OAuth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=80=EC=9E=85=20=ED=86=A0=ED=81=B0=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EB=B0=9C=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/OAuth2LoginSuccessHandler.java | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java index 801c53ed..f951a61b 100644 --- a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java @@ -19,7 +19,11 @@ import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.global.auth.bridge.NativeSessionBridgeService; +import gg.agit.konect.global.auth.JwtProvider; +import gg.agit.konect.global.auth.token.AuthCookieService; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.config.SecurityProperties; import gg.agit.konect.global.exception.CustomException; @@ -35,13 +39,16 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { @Value("${app.frontend.base-url}") private String frontendBaseUrl; - private static final int TEMP_SESSION_EXPIRATION_SECONDS = 600; - private final UserRepository userRepository; private final UnRegisteredUserRepository unRegisteredUserRepository; private final SecurityProperties securityProperties; private final ObjectProvider nativeSessionBridgeService; + private final SignupTokenService signupTokenService; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final AuthCookieService authCookieService; + @Override public void onAuthenticationSuccess( HttpServletRequest request, @@ -89,16 +96,8 @@ private void sendAdditionalInfoRequiredResponse( Provider provider, String providerId ) throws IOException { - HttpSession session = request.getSession(true); - session.setAttribute("email", email); - session.setAttribute("provider", provider); - - if (StringUtils.hasText(providerId)) { - session.setAttribute("providerId", providerId); - } - - session.setMaxInactiveInterval(TEMP_SESSION_EXPIRATION_SECONDS); - + String token = signupTokenService.issue(email, provider, providerId); + authCookieService.setSignupToken(request, response, token, signupTokenService.signupTtl()); response.sendRedirect(frontendBaseUrl + "/signup"); } @@ -107,11 +106,11 @@ private void sendLoginSuccessResponse( HttpServletResponse response, User user ) throws IOException { - HttpSession session = request.getSession(true); - session.setAttribute("userId", user.getId()); - - String redirectUri = (String)session.getAttribute("redirect_uri"); - session.removeAttribute("redirect_uri"); + HttpSession session = request.getSession(false); + String redirectUri = session == null ? null : (String)session.getAttribute("redirect_uri"); + if (session != null) { + session.removeAttribute("redirect_uri"); + } String safeRedirect = resolveSafeRedirect(redirectUri); @@ -122,8 +121,19 @@ private void sendLoginSuccessResponse( String bridgeToken = svc.issue(user.getId()); safeRedirect = appendBridgeToken(safeRedirect, bridgeToken); } + + authCookieService.clearRefreshToken(request, response); + authCookieService.clearSignupToken(request, response); + + response.sendRedirect(safeRedirect); + return; } + String refreshToken = refreshTokenService.issue(user.getId()); + authCookieService.setRefreshToken(request, response, refreshToken, refreshTokenService.refreshTtl()); + + authCookieService.clearSignupToken(request, response); + response.sendRedirect(safeRedirect); } From f9b7e4e42d0998c04c00e52c25181f36046c2cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:40:44 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EB=B8=8C=EB=A6=BF=EC=A7=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/bridge/NativeSessionController.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java index f4a1d42e..7edd8ddd 100644 --- a/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java +++ b/src/main/java/gg/agit/konect/global/auth/bridge/NativeSessionController.java @@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.RestController; import gg.agit.konect.global.auth.annotation.PublicApi; +import gg.agit.konect.domain.user.service.RefreshTokenService; +import gg.agit.konect.global.auth.token.AuthCookieService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; @@ -25,6 +27,8 @@ public class NativeSessionController { private String frontendBaseUrl; private final NativeSessionBridgeService nativeSessionBridgeService; + private final RefreshTokenService refreshTokenService; + private final AuthCookieService authCookieService; @PublicApi @GetMapping("/native/session/bridge") @@ -52,8 +56,10 @@ public void bridge( existing.invalidate(); } - HttpSession session = request.getSession(true); - session.setAttribute("userId", userId); + authCookieService.clearSignupToken(request, response); + + String refreshToken = refreshTokenService.issue(userId); + authCookieService.setRefreshToken(request, response, refreshToken, refreshTokenService.refreshTtl()); response.sendRedirect(frontendBaseUrl + "/home"); } From 85549fee42f80a6ed04ed467ff517fcd52a47374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:40:53 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gg/agit/konect/global/code/ApiResponseCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 9c9cfb3a..6f113e8d 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -51,6 +51,7 @@ public enum ApiResponseCode { FORBIDDEN_MEMBER_POSITION_CHANGE(HttpStatus.FORBIDDEN, "회원 직책 변경 권한이 없습니다."), FORBIDDEN_POSITION_NAME_CHANGE(HttpStatus.FORBIDDEN, "해당 직책의 이름은 변경할 수 없습니다."), FORBIDDEN_ROLE_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + FORBIDDEN_ORIGIN_ACCESS(HttpStatus.FORBIDDEN, "허용되지 않은 Origin 입니다."), // 404 Not Found (리소스를 찾을 수 없음) NO_HANDLER_FOUND(HttpStatus.NOT_FOUND, "유효하지 않은 API 경로입니다."), From 3e6b5fa11594a4c5ba120421681fb0ed17dd7961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 11:44:24 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gg/agit/konect/global/auth/JwtProvider.java | 2 +- src/main/java/gg/agit/konect/global/code/ApiResponseCode.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java index e2a9fadb..5341990a 100644 --- a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java +++ b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java @@ -90,7 +90,7 @@ public Integer getUserId(String token) { Date exp = claims.getExpirationTime(); if (exp == null || Instant.now().isAfter(exp.toInstant())) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.EXPIRED_TOKEN); } Object id = claims.getClaim(CLAIM_USER_ID); diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 6f113e8d..6671cd4e 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -40,6 +40,7 @@ public enum ApiResponseCode { // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), // 403 Forbidden (접근 권한 없음) FORBIDDEN_CHAT_ROOM_ACCESS(HttpStatus.FORBIDDEN, "채팅방에 접근할 권한이 없습니다."), From 0e926ea4f5e029c51b44a9e471e13ae1677e426d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 13:02:31 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=89=AC=20?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=EC=95=A1=EC=84=B8=EC=8A=A4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9D=91=EB=8B=B5=EA=B0=92=EC=9C=BC=EB=A1=9C=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 --- .../konect/domain/user/controller/UserApi.java | 3 ++- .../domain/user/controller/UserController.java | 6 +++--- .../domain/user/dto/UserAccessTokenResponse.java | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/user/dto/UserAccessTokenResponse.java diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java index c6e97f09..0c538f87 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import gg.agit.konect.domain.user.dto.SignupRequest; +import gg.agit.konect.domain.user.dto.UserAccessTokenResponse; import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.domain.user.dto.UserUpdateRequest; import gg.agit.konect.global.auth.annotation.PublicApi; @@ -62,7 +63,7 @@ ResponseEntity updateMyInfo( @Operation(summary = "리프레시 토큰으로 액세스 토큰을 재발급한다.") @PostMapping("/refresh") @PublicApi - ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response); + ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response); @Operation(summary = "회원탈퇴를 한다.") @DeleteMapping("/withdraw") diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java index cc17f3eb..7485c90a 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java @@ -7,6 +7,7 @@ import org.springframework.web.util.WebUtils; import gg.agit.konect.domain.user.dto.SignupRequest; +import gg.agit.konect.domain.user.dto.UserAccessTokenResponse; import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.domain.user.dto.UserUpdateRequest; import gg.agit.konect.domain.user.service.UserService; @@ -87,15 +88,14 @@ public ResponseEntity logout(HttpServletRequest request, HttpServletRespon @Override @PublicApi - public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { + public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE); RefreshTokenService.Rotated rotated = refreshTokenService.rotate(refreshToken); String accessToken = jwtProvider.createToken(rotated.userId()); - response.setHeader("Authorization", "Bearer " + accessToken); authCookieService.setRefreshToken(request, response, rotated.refreshToken(), refreshTokenService.refreshTtl()); - return ResponseEntity.ok().build(); + return ResponseEntity.ok(new UserAccessTokenResponse(accessToken)); } @Override diff --git a/src/main/java/gg/agit/konect/domain/user/dto/UserAccessTokenResponse.java b/src/main/java/gg/agit/konect/domain/user/dto/UserAccessTokenResponse.java new file mode 100644 index 00000000..0d3ac0b2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/dto/UserAccessTokenResponse.java @@ -0,0 +1,15 @@ +package gg.agit.konect.domain.user.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record UserAccessTokenResponse( + @Schema( + description = "액세스 토큰", + example = "eyJhbGciOiJIUzI1NiJ9...", + requiredMode = REQUIRED + ) + String accessToken +) { +} From f433f9ae920ba7b1e7a9d44e584b2b510d6f54da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 13:04:04 +0900 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20jwt=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=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 --- .../agit/konect/global/config/SwaggerConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java index 9c32f24b..24549bea 100644 --- a/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java +++ b/src/main/java/gg/agit/konect/global/config/SwaggerConfig.java @@ -8,8 +8,11 @@ import org.springdoc.core.models.GroupedOpenApi; +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; @Configuration @@ -25,12 +28,24 @@ public SwaggerConfig( @Bean public OpenAPI openAPI() { + String jwt = "Jwt Authentication"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .description("토큰값을 입력하여 인증을 활성화할 수 있습니다.") + .bearerFormat("JWT") + ); + Server server = new Server(); server.setUrl(serverUrl); return new OpenAPI() .openapi("3.1.0") .info(apiInfo()) + .addSecurityItem(securityRequirement) + .components(components) .addServersItem(server); } From 1e903c6f4d5bb3eb4cb726272388a6646e9c3d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 14:08:07 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=BD=94=EB=93=9C=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 --- .../agit/konect/global/auth/JwtProvider.java | 20 +++++++++++-------- .../konect/global/code/ApiResponseCode.java | 5 +++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java index 5341990a..165f00c6 100644 --- a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java +++ b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java @@ -59,43 +59,47 @@ public String createToken(Integer userId) { public Integer getUserId(String token) { if (!StringUtils.hasText(token)) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.MISSING_ACCESS_TOKEN); } SignedJWT jwt; try { jwt = SignedJWT.parse(token); } catch (Exception e) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.MALFORMED_ACCESS_TOKEN); } try { if (!jwt.verify(new MACVerifier(resolveSecretBytes()))) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_SIGNATURE); } } catch (JOSEException e) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_SIGNATURE); } JWTClaimsSet claims; try { claims = jwt.getJWTClaimsSet(); } catch (Exception e) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); } if (!resolveIssuer().equals(claims.getIssuer())) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_ISSUER); } Date exp = claims.getExpirationTime(); - if (exp == null || Instant.now().isAfter(exp.toInstant())) { + if (exp == null) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); + } + + if (Instant.now().isAfter(exp.toInstant())) { throw CustomException.of(ApiResponseCode.EXPIRED_TOKEN); } Object id = claims.getClaim(CLAIM_USER_ID); if (!(id instanceof Number number)) { - throw CustomException.of(ApiResponseCode.INVALID_SESSION); + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); } return number.intValue(); diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 6671cd4e..113b2f1f 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -41,6 +41,11 @@ public enum ApiResponseCode { // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + MISSING_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰이 필요합니다."), + MALFORMED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰 형식이 올바르지 않습니다."), + INVALID_ACCESS_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "액세스 토큰 서명이 올바르지 않습니다."), + INVALID_ACCESS_TOKEN_ISSUER(HttpStatus.UNAUTHORIZED, "액세스 토큰 발급자가 올바르지 않습니다."), + INVALID_ACCESS_TOKEN_CLAIMS(HttpStatus.UNAUTHORIZED, "액세스 토큰 정보가 올바르지 않습니다."), // 403 Forbidden (접근 권한 없음) FORBIDDEN_CHAT_ROOM_ACCESS(HttpStatus.FORBIDDEN, "채팅방에 접근할 권한이 없습니다."), From c22b2a9108762060e4dcd99b510a57230131254a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 14:18:47 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20TTL=EC=9D=84?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=83=81=EC=88=98=EB=A1=9C=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/RefreshTokenService.java | 11 +++-------- .../domain/user/service/SignupTokenService.java | 11 +++-------- .../gg/agit/konect/global/auth/JwtProperties.java | 3 +-- .../java/gg/agit/konect/global/auth/JwtProvider.java | 11 ++--------- 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java index 49dfb346..f8ec8fcb 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/RefreshTokenService.java @@ -8,7 +8,6 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; -import org.springframework.beans.factory.annotation.Value; import org.springframework.util.StringUtils; import gg.agit.konect.global.code.ApiResponseCode; @@ -21,6 +20,8 @@ public class RefreshTokenService { private static final int TOKEN_BYTES = 32; + private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(30); + private static final String ACTIVE_PREFIX = "auth:refresh:active:"; private static final String REVOKED_PREFIX = "auth:refresh:revoked:"; private static final String USER_SET_PREFIX = "auth:refresh:user:"; @@ -37,14 +38,8 @@ public class RefreshTokenService { private final StringRedisTemplate redis; - @Value("${app.auth.refresh-token-ttl-seconds:2592000}") - private long refreshTokenTtlSeconds; - public Duration refreshTtl() { - if (refreshTokenTtlSeconds <= 0) { - throw new IllegalStateException("app.auth.refresh-token-ttl-seconds must be positive"); - } - return Duration.ofSeconds(refreshTokenTtlSeconds); + return REFRESH_TOKEN_TTL; } public String issue(Integer userId) { diff --git a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java index d87a60d7..fb2285b3 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/SignupTokenService.java @@ -8,7 +8,6 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; -import org.springframework.beans.factory.annotation.Value; import org.springframework.util.StringUtils; import gg.agit.konect.domain.user.enums.Provider; @@ -21,6 +20,8 @@ public class SignupTokenService { private static final int TOKEN_BYTES = 32; + + private static final Duration SIGNUP_TOKEN_TTL = Duration.ofMinutes(10); private static final String KEY_PREFIX = "auth:signup:"; private static final String DELIMITER = "|"; private static final int EXPECTED_PARTS = 3; @@ -37,14 +38,8 @@ public class SignupTokenService { private final StringRedisTemplate redis; - @Value("${app.auth.signup-token-ttl-seconds:600}") - private long signupTokenTtlSeconds; - public Duration signupTtl() { - if (signupTokenTtlSeconds <= 0) { - throw new IllegalStateException("app.auth.signup-token-ttl-seconds must be positive"); - } - return Duration.ofSeconds(signupTokenTtlSeconds); + return SIGNUP_TOKEN_TTL; } public String issue(String email, Provider provider, String providerId) { diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProperties.java b/src/main/java/gg/agit/konect/global/auth/JwtProperties.java index 4abd326a..38826d42 100644 --- a/src/main/java/gg/agit/konect/global/auth/JwtProperties.java +++ b/src/main/java/gg/agit/konect/global/auth/JwtProperties.java @@ -5,7 +5,6 @@ @ConfigurationProperties(prefix = "app.jwt") public record JwtProperties( String secret, - String issuer, - long accessTokenTtlSeconds + String issuer ) { } diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java index 165f00c6..860056f4 100644 --- a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java +++ b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java @@ -27,6 +27,7 @@ public class JwtProvider { private static final int MIN_HS256_SECRET_BYTES = 32; private static final String CLAIM_USER_ID = "id"; + private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(15); private final JwtProperties properties; @@ -36,7 +37,7 @@ public String createToken(Integer userId) { } Instant now = Instant.now(); - Instant expiresAt = now.plus(accessTtl()); + Instant expiresAt = now.plus(ACCESS_TOKEN_TTL); JWTClaimsSet claims = new JWTClaimsSet.Builder() .issuer(resolveIssuer()) @@ -105,14 +106,6 @@ public Integer getUserId(String token) { return number.intValue(); } - public Duration accessTtl() { - long seconds = properties.accessTokenTtlSeconds(); - if (seconds <= 0) { - throw new IllegalStateException("app.jwt.access-token-ttl-seconds must be positive"); - } - return Duration.ofSeconds(seconds); - } - private String resolveIssuer() { String issuer = properties.issuer(); if (!StringUtils.hasText(issuer)) { From 65fd413aaa51fd0223d749c18364c1dae51e044c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 14:48:13 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 19 ++++++ .../auth/AccessTokenBlacklistService.java | 60 +++++++++++++++++++ .../agit/konect/global/auth/JwtProvider.java | 10 ++++ .../konect/global/code/ApiResponseCode.java | 1 + 4 files changed, 90 insertions(+) create mode 100644 src/main/java/gg/agit/konect/global/auth/AccessTokenBlacklistService.java diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java index 7485c90a..367d4de0 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java @@ -13,6 +13,7 @@ import gg.agit.konect.domain.user.service.UserService; import gg.agit.konect.global.auth.annotation.PublicApi; import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.global.auth.AccessTokenBlacklistService; import gg.agit.konect.global.auth.JwtProvider; import gg.agit.konect.global.auth.token.AuthCookieService; import gg.agit.konect.domain.user.service.RefreshTokenService; @@ -28,11 +29,15 @@ @RequestMapping("/users") public class UserController implements UserApi { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private final UserService userService; private final SignupTokenService signupTokenService; private final JwtProvider jwtProvider; private final RefreshTokenService refreshTokenService; private final AuthCookieService authCookieService; + private final AccessTokenBlacklistService accessTokenBlacklistService; @Override @PublicApi @@ -77,6 +82,9 @@ public ResponseEntity updateMyInfo( @Override @PublicApi public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + String accessToken = resolveBearerToken(request); + accessTokenBlacklistService.blacklist(accessToken); + String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE); refreshTokenService.revoke(refreshToken); @@ -89,6 +97,9 @@ public ResponseEntity logout(HttpServletRequest request, HttpServletRespon @Override @PublicApi public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { + String oldAccessToken = resolveBearerToken(request); + accessTokenBlacklistService.blacklist(oldAccessToken); + String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE); RefreshTokenService.Rotated rotated = refreshTokenService.rotate(refreshToken); @@ -98,6 +109,14 @@ public ResponseEntity refresh(HttpServletRequest reques return ResponseEntity.ok(new UserAccessTokenResponse(accessToken)); } + private String resolveBearerToken(HttpServletRequest request) { + String authorization = request.getHeader(AUTHORIZATION_HEADER); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + return authorization.substring(BEARER_PREFIX.length()); + } + @Override public ResponseEntity withdraw( HttpServletRequest request, diff --git a/src/main/java/gg/agit/konect/global/auth/AccessTokenBlacklistService.java b/src/main/java/gg/agit/konect/global/auth/AccessTokenBlacklistService.java new file mode 100644 index 00000000..e63fe457 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/AccessTokenBlacklistService.java @@ -0,0 +1,60 @@ +package gg.agit.konect.global.auth; + +import java.time.Duration; +import java.time.Instant; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AccessTokenBlacklistService { + + private static final String BLACKLIST_PREFIX = "auth:access:blacklist:"; + + private final StringRedisTemplate redis; + + public void blacklist(String token) { + if (!StringUtils.hasText(token)) { + return; + } + + try { + SignedJWT jwt = SignedJWT.parse(token); + JWTClaimsSet claims = jwt.getJWTClaimsSet(); + + String jti = claims.getJWTID(); + if (!StringUtils.hasText(jti) || claims.getExpirationTime() == null) { + return; + } + + Instant exp = claims.getExpirationTime().toInstant(); + Duration ttl = Duration.between(Instant.now(), exp); + if (ttl.isNegative() || ttl.isZero()) { + return; + } + + redis.opsForValue().set(key(jti), "1", ttl); + } catch (Exception ignored) { + // ignore + } + } + + public boolean isBlacklisted(String jti) { + if (!StringUtils.hasText(jti)) { + return false; + } + Boolean exists = redis.hasKey(key(jti)); + return Boolean.TRUE.equals(exists); + } + + private String key(String jti) { + return BLACKLIST_PREFIX + jti; + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java index 860056f4..e50fd50d 100644 --- a/src/main/java/gg/agit/konect/global/auth/JwtProvider.java +++ b/src/main/java/gg/agit/konect/global/auth/JwtProvider.java @@ -30,6 +30,7 @@ public class JwtProvider { private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(15); private final JwtProperties properties; + private final AccessTokenBlacklistService accessTokenBlacklistService; public String createToken(Integer userId) { if (userId == null) { @@ -98,6 +99,15 @@ public Integer getUserId(String token) { throw CustomException.of(ApiResponseCode.EXPIRED_TOKEN); } + String jti = claims.getJWTID(); + if (!StringUtils.hasText(jti)) { + throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); + } + + if (accessTokenBlacklistService.isBlacklisted(jti)) { + throw CustomException.of(ApiResponseCode.BLACKLISTED_ACCESS_TOKEN); + } + Object id = claims.getClaim(CLAIM_USER_ID); if (!(id instanceof Number number)) { throw CustomException.of(ApiResponseCode.INVALID_ACCESS_TOKEN_CLAIMS); diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 113b2f1f..46d75782 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -46,6 +46,7 @@ public enum ApiResponseCode { INVALID_ACCESS_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "액세스 토큰 서명이 올바르지 않습니다."), INVALID_ACCESS_TOKEN_ISSUER(HttpStatus.UNAUTHORIZED, "액세스 토큰 발급자가 올바르지 않습니다."), INVALID_ACCESS_TOKEN_CLAIMS(HttpStatus.UNAUTHORIZED, "액세스 토큰 정보가 올바르지 않습니다."), + BLACKLISTED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "폐기된 액세스 토큰입니다."), // 403 Forbidden (접근 권한 없음) FORBIDDEN_CHAT_ROOM_ACCESS(HttpStatus.FORBIDDEN, "채팅방에 접근할 권한이 없습니다."), From 4247b63783e1abeb5f908da28771b77e9036c3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 29 Jan 2026 15:01:06 +0900 Subject: [PATCH 15/15] =?UTF-8?q?chore:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=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 --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index b421a40e..513e3cf6 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit b421a40e7db37a33fa0933319764afed4c0889c2 +Subproject commit 513e3cf67f61e76d6656d68069c17d3b1b1e52a0