-
Notifications
You must be signed in to change notification settings - Fork 1
[FEAT] kakao login 구현 #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null 가능 필드로 인한 NPE 위험
적용 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| 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 | ||
| ) { | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PR 요구사항 반영: 액세스 토큰 만료 시각 포함 PR 요약에 “만료 시간 반환”이 명시되어 있습니다. 현재 DTO에는 없어 프론트가 저장할 수 없습니다. @Builder
public record TokenResponse(
String accessToken,
- String refreshToken
+ String refreshToken,
+ long accessTokenExpiresAt // epoch millis
) {
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| @Column(nullable = false) | ||
| private String name; | ||
|
|
||
| @Column(nullable = false) | ||
| private String email; | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 회원 당 리프레시 토큰 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
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+21
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 리프레시 토큰 평문 저장 금지 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 토큰 검증 실패 시 500 발생 가능 — 예외 처리 후 체인 계속 진행
적용 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 |
||
|
|
||
| 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; | ||
| } | ||
| } | ||
| 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; | ||
|
|
||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 토큰 유형 검증 및 소유자 확인 추가 필요
적용 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
외부 호출 타임아웃 미설정 + block() 사용
타임아웃 없는 블로킹 호출은 요청 스레드 고갈/전파 장애를 초래할 수 있습니다. 연결/응답 타임아웃을 설정하고 block(Duration)으로 상한을 두세요.
📝 Committable suggestion
🤖 Prompt for AI Agents