Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,16 @@ dependencies {

implementation 'me.paulschwarz:spring-dotenv:4.0.0' // .env 읽기

// 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")

// Spring Security
implementation("org.springframework.boot:spring-boot-starter-security")

// WebClient
implementation("org.springframework.boot:spring-boot-starter-webflux")
}

tasks.named('test') {
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/com/example/demo/auth/client/KakaoOAuthClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.example.demo.auth.client;

import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;

@Component
public class KakaoOAuthClient {

private final WebClient webClient;

public KakaoOAuthClient(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl("https://kapi.kakao.com").build();
}
Comment on lines +12 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

외부 호출 타임아웃 미설정 + block() 사용

타임아웃 없는 블로킹 호출은 요청 스레드 고갈/전파 장애를 초래할 수 있습니다. 연결/응답 타임아웃을 설정하고 block(Duration)으로 상한을 두세요.

-import org.springframework.stereotype.Component;
+import org.springframework.stereotype.Component;
+import java.time.Duration;
+import io.netty.channel.ChannelOption;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import reactor.netty.http.client.HttpClient;
 import org.springframework.web.reactive.function.client.WebClient;
 import org.springframework.web.reactive.function.client.WebClientResponseException;
 
 @Component
 public class KakaoOAuthClient {
 
     private final WebClient webClient;
 
     public KakaoOAuthClient(WebClient.Builder webClientBuilder) {
-        this.webClient = webClientBuilder.baseUrl("https://kapi.kakao.com").build();
+        this.webClient = webClientBuilder
+                .clientConnector(new ReactorClientHttpConnector(
+                        HttpClient.create()
+                                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
+                                .responseTimeout(Duration.ofSeconds(3))
+                ))
+                .baseUrl("https://kapi.kakao.com")
+                .build();
     }
 
     public KakaoUserInfo retrieveUserInfo(String accessToken) {
         try {
             return webClient.get()
                     .uri("/v2/user/me")
                     .headers(headers -> headers.setBearerAuth(accessToken))
                     .retrieve()
                     .bodyToMono(KakaoUserInfo.class)
-                    .block();
+                    .block(Duration.ofSeconds(5));
         } catch (WebClientResponseException e) {
             throw new RuntimeException("카카오 API 호출 실패: " + e.getResponseBodyAsString(), e);
         }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public KakaoOAuthClient(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl("https://kapi.kakao.com").build();
}
import org.springframework.stereotype.Component;
import java.time.Duration;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.http.client.HttpClient;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
@Component
public class KakaoOAuthClient {
private final WebClient webClient;
public KakaoOAuthClient(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.responseTimeout(Duration.ofSeconds(3))
))
.baseUrl("https://kapi.kakao.com")
.build();
}
public KakaoUserInfo retrieveUserInfo(String accessToken) {
try {
return webClient.get()
.uri("/v2/user/me")
.headers(headers -> headers.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(KakaoUserInfo.class)
.block(Duration.ofSeconds(5));
} catch (WebClientResponseException e) {
throw new RuntimeException("카카오 API 호출 실패: " + e.getResponseBodyAsString(), e);
}
}
}
🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/client/KakaoOAuthClient.java around lines
12 to 14, the WebClient is built without any connection/response timeouts and
the code uses blocking calls without an upper bound; update the WebClient to use
a Reactor Netty HttpClient (or appropriate ClientHttpConnector) configured with
connect and response/read timeouts and then replace any plain .block() calls
with .block(Duration.ofSeconds(...)) to enforce a maximum wait; ensure the
connector is passed into WebClient.builder().clientConnector(...) and pick
sensible timeout values (e.g. connect 2s, response/read 5–10s) so external calls
cannot exhaust request threads.


public KakaoUserInfo retrieveUserInfo(String accessToken) {
try {
return webClient.get()
.uri("/v2/user/me")
.headers(headers -> headers.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(KakaoUserInfo.class)
.block();
} catch (WebClientResponseException e) {
throw new RuntimeException("카카오 API 호출 실패: " + e.getResponseBodyAsString(), e);
}
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/example/demo/auth/client/KakaoUserInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.example.demo.auth.client;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
public class KakaoUserInfo {

private String id;
private Properties properties;
@JsonProperty("kakao_account")
private KakaoAccount kakaoAccount;

@Getter
@Setter
@NoArgsConstructor
public static class Properties {
private String nickname;
}

@Getter
@Setter
@NoArgsConstructor
public static class KakaoAccount {
private String email;
}

public String getNickName() {
return this.properties.nickname;
}

public String getEmail() {
return this.kakaoAccount.email;
}
Comment on lines +30 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Null 가능 필드로 인한 NPE 위험

properties 또는 kakaoAccount가 null일 수 있어 NPE가 발생합니다. null‑safe 접근으로 변경하세요.

적용 diff:

     public String getNickName() {
-        return this.properties.nickname;
+        return this.properties != null ? this.properties.getNickname() : null;
     }
 
     public String getEmail() {
-        return this.kakaoAccount.email;
+        return this.kakaoAccount != null ? this.kakaoAccount.getEmail() : null;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public String getNickName() {
return this.properties.nickname;
}
public String getEmail() {
return this.kakaoAccount.email;
}
public String getNickName() {
return this.properties != null ? this.properties.getNickname() : null;
}
public String getEmail() {
return this.kakaoAccount != null ? this.kakaoAccount.getEmail() : null;
}
🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/client/KakaoUserInfo.java around lines 30
to 36, the getters access properties.nickname and kakaoAccount.email directly
which can throw NPE if properties or kakaoAccount are null; change both getters
to be null-safe by checking for null (e.g., return properties != null ?
properties.nickname : null and return kakaoAccount != null ? kakaoAccount.email
: null) or use Optional.ofNullable(...) to safely extract the nested values so
the methods return null (or an empty Optional) instead of throwing.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.demo.auth.dto;

public record AccessTokenRequest(
String accessToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.demo.auth.dto;

public record RefreshTokenRequest(
String refreshToken
) {
}
10 changes: 10 additions & 0 deletions src/main/java/com/example/demo/auth/dto/TokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.demo.auth.dto;

import lombok.Builder;

@Builder
public record TokenResponse(
String accessToken,
String refreshToken
) {
Comment on lines +5 to +9
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

PR 요구사항 반영: 액세스 토큰 만료 시각 포함

PR 요약에 “만료 시간 반환”이 명시되어 있습니다. 현재 DTO에는 없어 프론트가 저장할 수 없습니다.

 @Builder
 public record TokenResponse(
         String accessToken,
-        String refreshToken
+        String refreshToken,
+        long accessTokenExpiresAt // epoch millis
 ) {
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Builder
public record TokenResponse(
String accessToken,
String refreshToken
) {
@Builder
public record TokenResponse(
String accessToken,
String refreshToken,
long accessTokenExpiresAt // epoch millis
) {
🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/dto/TokenResponse.java around lines 5 to
9, the TokenResponse record currently contains only accessToken and refreshToken
but the PR requires returning the access token expiry; add a new field (e.g.,
Instant expiresAt or long expiresAtEpochMillis) to the record signature, update
the @Builder usage and any places that construct TokenResponse to supply this
value, and ensure JSON serialization format is appropriate (add @JsonFormat or
map epoch millis if using Instant) so the frontend can persist the expiration
time.

}
24 changes: 24 additions & 0 deletions src/main/java/com/example/demo/auth/entity/Member.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example.demo.auth.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Getter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String socialId;

Comment on lines +16 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

socialId에 유니크 제약 필요

소셜 로그인 키는 계정 식별자입니다. DB 차원의 유니크 보장이 안전합니다.

적용 diff:

-    @Column(nullable = false)
+    @Column(nullable = false, unique = true, length = 100)
     private String socialId;

추가로 인덱스까지 보장하려면(선택):

// 클래스 상단에 추가
//@Table(indexes = @Index(name = "uk_member_social_id", columnList = "socialId", unique = true))
🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/entity/Member.java around lines 16 to 18,
the socialId column lacks a DB-level uniqueness constraint; update the entity to
enforce uniqueness (e.g., mark the column as unique or add a @Table with a
unique @Index on socialId) so the database guarantees unique social IDs, and
ensure corresponding schema/migration changes are applied to add the unique
constraint/index.

@Column(nullable = false)
private String name;

@Column(nullable = false)
private String email;
}
27 changes: 27 additions & 0 deletions src/main/java/com/example/demo/auth/entity/RefreshToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.demo.auth.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long memberId;

@Column(length = 500, nullable = false)
private String token;
Comment on lines +18 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

회원 당 리프레시 토큰 1개 보장 및 조회 성능을 위한 제약/인덱스 추가

중복 레코드가 저장될 수 있어 데이터 무결성 및 갱신 로직이 깨질 수 있습니다. memberId에 유니크, token에 인덱스를 추가하세요.

 package com.example.demo.auth.entity;

 import jakarta.persistence.*;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.NoArgsConstructor;

 @Entity
+@Table(
+    name = "refresh_token",
+    uniqueConstraints = @UniqueConstraint(name = "uk_refresh_token_member", columnNames = "member_id"),
+    indexes = @Index(name = "idx_refresh_token_token", columnList = "token")
+)
 public class RefreshToken {
...
-    @Column(nullable = false)
-    private Long memberId;
+    @Column(name = "member_id", nullable = false)
+    private Long memberId;
 
-    @Column(length = 500, nullable = false)
+    @Column(length = 1024, nullable = false)
     private String token;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Column(nullable = false)
private Long memberId;
@Column(length = 500, nullable = false)
private String token;
package com.example.demo.auth.entity;
import jakarta.persistence.*;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.NoArgsConstructor;
@Entity
@Table(
name = "refresh_token",
uniqueConstraints = @UniqueConstraint(name = "uk_refresh_token_member", columnNames = "member_id"),
indexes = @Index(name = "idx_refresh_token_token", columnList = "token")
)
public class RefreshToken {
...
@Column(name = "member_id", nullable = false)
private Long memberId;
@Column(length = 1024, nullable = false)
private String token;
...
}
🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/entity/RefreshToken.java around lines 18
to 22, the entity currently allows duplicate memberId and lacks an index on
token; add a uniqueness constraint for memberId and an index on token to enforce
one refresh token per member and improve lookup performance. Modify the
entity-level annotations to declare a unique constraint on memberId (or mark the
memberId column unique) and add a database index for the token column (e.g., via
@Table(indexes=...) or appropriate JPA/Hibernate index annotation) so the schema
enforces uniqueness and token lookups use the index.

Comment on lines +21 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

리프레시 토큰 평문 저장 금지

DB 유출 시 즉시 오용됩니다. 토큰은 해시(예: HMAC-SHA256)로 저장하고, 검증 시 동일 방식으로 비교하세요. 토큰 로테이션/폐기도 함께 고려하십시오.


public void updateToken(String token) {
this.token = token;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.example.demo.auth.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenProvider jwtTokenProvider;

public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {

String token = extractToken(request);
if (token != null) {
var authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}

filterChain.doFilter(request, response);
}
Comment on lines +23 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

토큰 검증 실패 시 500 발생 가능 — 예외 처리 후 체인 계속 진행

jwtTokenProvider.getAuthentication(token)에서 발생하는 예외가 필터를 중단시키며 500을 유발합니다. 검증 실패 시 SecurityContext를 비우고 체인을 계속 진행하도록 처리하세요. 또한 이미 인증이 설정된 경우 재설정을 피하세요.

적용 diff:

-        String token = extractToken(request);
-        if (token != null) {
-            var authentication = jwtTokenProvider.getAuthentication(token);
-            SecurityContextHolder.getContext().setAuthentication(authentication);
-        }
+        String token = extractToken(request);
+        if (StringUtils.hasText(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
+            try {
+                org.springframework.security.core.Authentication authentication = jwtTokenProvider.getAuthentication(token);
+                SecurityContextHolder.getContext().setAuthentication(authentication);
+            } catch (RuntimeException ex) {
+                SecurityContextHolder.clearContext();
+                // 검증 실패: 인증 미설정 상태로 계속 진행
+            }
+        }
         filterChain.doFilter(request, response);

필요 import(선택):

import org.springframework.security.core.Authentication;
🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/jwt/JwtAuthenticationFilter.java around
lines 23 to 36, wrap the call to jwtTokenProvider.getAuthentication(token) in a
try-catch so any validation exception does not abort the filter and return 500;
only attempt to set the SecurityContext if there is a token AND the current
SecurityContext has no existing Authentication, and on exception clear the
SecurityContext (or leave it untouched if an authentication already existed)
then always continue with filterChain.doFilter(request, response). Ensure you
import org.springframework.security.core.Authentication if needed.


private String extractToken(HttpServletRequest request) {
String header = "Authorization";
String prefix = "Bearer ";
String bearerToken = request.getHeader(header);

if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(prefix)) {
return bearerToken.substring(prefix.length());
}

return null;
}
}
20 changes: 20 additions & 0 deletions src/main/java/com/example/demo/auth/jwt/JwtProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.demo.auth.jwt;


import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Configuration
@Getter
public class JwtProperties {
@Value("${jwt.secret}")
private String secret;

@Value("${jwt.access-token-expiration-time}")
private long accessTokenExpirationTime;

@Value("${jwt.refresh-token-expiration-time}")
private long refreshTokenExpirationTime;

}
89 changes: 89 additions & 0 deletions src/main/java/com/example/demo/auth/jwt/JwtTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.example.demo.auth.jwt;

import com.example.demo.auth.dto.TokenResponse;
import com.example.demo.auth.service.RefreshTokenService;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Collections;
import java.util.Date;

@Component
public class JwtTokenProvider {

private final JwtProperties jwtProperties;
private final RefreshTokenService refreshTokenService;
private final SecretKey key;

public JwtTokenProvider(JwtProperties jwtProperties, RefreshTokenService refreshTokenService) {
this.jwtProperties = jwtProperties;
this.refreshTokenService = refreshTokenService;
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.getSecret()));
}

public String createAccessToken(Long userId) {
return createToken(userId, jwtProperties.getAccessTokenExpirationTime());
}

public String createRefreshToken(Long userId) {
return createToken(userId, jwtProperties.getRefreshTokenExpirationTime());
}

private String createToken(Long userId, long expireTime) {
Date now = new Date();
return Jwts.builder()
.issuer("wisecard")
.claim("id", userId.toString())
.issuedAt(now)
.expiration(new Date(now.getTime() + expireTime))
.signWith(key)
.compact();
}
Comment on lines +31 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Access/Refresh 토큰 구분 부재 — Refresh 토큰으로 인증 가능한 보안 취약점

현재 Access/Refresh가 동일한 클레임 구조/키로 서명되어 필터에서 Refresh 토큰도 인증으로 수용됩니다. 토큰 타입을 클레임으로 구분하고, 인증 시 Access만 허용하세요.

적용 diff:

-    public String createAccessToken(Long userId) {
-        return createToken(userId, jwtProperties.getAccessTokenExpirationTime());
-    }
+    public String createAccessToken(Long userId) {
+        return createToken(userId, jwtProperties.getAccessTokenExpirationTime(), "ACCESS");
+    }
@@
-    public String createRefreshToken(Long userId) {
-        return createToken(userId, jwtProperties.getRefreshTokenExpirationTime());
-    }
+    public String createRefreshToken(Long userId) {
+        return createToken(userId, jwtProperties.getRefreshTokenExpirationTime(), "REFRESH");
+    }
@@
-    private String createToken(Long userId, long expireTime) {
+    private String createToken(Long userId, long expireTime, String type) {
         Date now = new Date();
         return Jwts.builder()
                 .issuer("wisecard")
                 .claim("id", userId.toString())
+                .claim("type", type)
                 .issuedAt(now)
                 .expiration(new Date(now.getTime() + expireTime))
                 .signWith(key)
                 .compact();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public String createAccessToken(Long userId) {
return createToken(userId, jwtProperties.getAccessTokenExpirationTime());
}
public String createRefreshToken(Long userId) {
return createToken(userId, jwtProperties.getRefreshTokenExpirationTime());
}
private String createToken(Long userId, long expireTime) {
Date now = new Date();
return Jwts.builder()
.issuer("wisecard")
.claim("id", userId.toString())
.issuedAt(now)
.expiration(new Date(now.getTime() + expireTime))
.signWith(key)
.compact();
}
public String createAccessToken(Long userId) {
return createToken(userId, jwtProperties.getAccessTokenExpirationTime(), "ACCESS");
}
public String createRefreshToken(Long userId) {
return createToken(userId, jwtProperties.getRefreshTokenExpirationTime(), "REFRESH");
}
private String createToken(Long userId, long expireTime, String type) {
Date now = new Date();
return Jwts.builder()
.issuer("wisecard")
.claim("id", userId.toString())
.claim("type", type)
.issuedAt(now)
.expiration(new Date(now.getTime() + expireTime))
.signWith(key)
.compact();
}
🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/jwt/JwtTokenProvider.java around lines 31
to 48, the createToken method issues Access and Refresh tokens with identical
claims and signing, allowing refresh tokens to be used for authentication;
change createToken to accept a tokenType parameter (e.g., "access" or "refresh")
and add a distinguishing claim like "typ" or "token_type" with that value,
update createAccessToken to pass "access" and createRefreshToken to pass
"refresh", and then update the authentication filter to explicitly validate that
the token_type claim equals "access" before treating the token as an
authentication token.


public TokenResponse reissueToken(String token) {
if (!refreshTokenService.existsToken(token)) {
throw new RuntimeException("refresh token을 찾을 수 없습니다.");
}

Claims claims = validateToken(token);
Long userId = Long.parseLong(claims.get("id").toString());

String accessToken = createAccessToken(userId);
String refreshToken = createRefreshToken(userId);

refreshTokenService.updateToken(userId, refreshToken);

return new TokenResponse(accessToken, refreshToken);
}
Comment on lines +50 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

토큰 유형 검증 및 소유자 확인 추가 필요

  • 재발급: Refresh 토큰 유형인지 확인하고, 해당 유저 소유인지 DB로 검증하세요.
  • 인증: Access 토큰만 허용하세요.

적용 diff:

     public TokenResponse reissueToken(String token) {
-        if (!refreshTokenService.existsToken(token)) {
-            throw new RuntimeException("refresh token을 찾을 수 없습니다.");
-        }
-
-        Claims claims = validateToken(token);
-        Long userId = Long.parseLong(claims.get("id").toString());
+        if (!refreshTokenService.existsToken(token)) {
+            throw new RuntimeException("refresh token을 찾을 수 없습니다.");
+        }
+        Claims claims = validateToken(token);
+        String type = claims.get("type", String.class);
+        if (!"REFRESH".equals(type)) {
+            throw new RuntimeException("refresh 토큰이 아닙니다.");
+        }
+        Long userId = Long.parseLong(claims.get("id", String.class));
+        if (!refreshTokenService.isOwnedBy(userId, token)) {
+            throw new RuntimeException("등록되지 않은 refresh 토큰입니다.");
+        }
@@
         return new TokenResponse(accessToken, refreshToken);
     }
@@
     public Authentication getAuthentication(String token) {
         Claims claims = validateToken(token);
-        String userId = claims.get("id").toString();
+        String type = claims.get("type", String.class);
+        if (!"ACCESS".equals(type)) {
+            throw new RuntimeException("access 토큰이 아닙니다.");
+        }
+        String userId = claims.get("id", String.class);
 
         User user = new User(userId, "", Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
         return new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities());
     }

지원 메서드(서비스/리포지토리) 추가는 RefreshTokenService 코멘트 참고.

Also applies to: 82-88

🤖 Prompt for AI Agents
In src/main/java/com/example/demo/auth/jwt/JwtTokenProvider.java around lines
50-64 (and similarly apply the same checks to lines 82-88), the reissueToken
path must verify the token is a refresh token and that it belongs to the
expected user in the database; update the logic to (1) validate the token
contains a "type" claim equal to "REFRESH" and reject if not, (2) extract the
user id from claims and call a RefreshTokenService method that verifies the
token belongs to that user (or add such a service/repository method and use it),
and (3) throw a clear, specific exception when type/ownership checks fail; also
ensure any authentication flow only accepts tokens whose "type" claim equals
"ACCESS" and reject other types.


public Claims validateToken(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (IllegalArgumentException | UnsupportedJwtException | MalformedJwtException | SecurityException e) {
throw new RuntimeException("잘못된 토큰입니다.");
} catch (ExpiredJwtException e) {
throw new RuntimeException("만료된 토큰입니다.");
} catch (Exception e) {
throw new RuntimeException("토큰 검증 중 알 수 없는 오류가 발생했습니다.");
}
}

public Authentication getAuthentication(String token) {
Claims claims = validateToken(token);
String userId = claims.get("id").toString();

User user = new User(userId, "", Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
return new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.demo.auth.repository;

import com.example.demo.auth.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findBySocialId(String socialId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.demo.auth.repository;

import com.example.demo.auth.entity.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByMemberId(Long memberId);

Boolean existsByToken(String token);
}
Loading
Loading