Skip to content
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

feature/#64 rtr 기반 refresh token 발행 기능 구현 #65

Merged
Merged
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package sorisoop.soridam.api.auth.application;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import sorisoop.soridam.api.auth.presentation.request.jwt.JwtLoginRequest;
import sorisoop.soridam.api.auth.presentation.request.jwt.RefreshTokenRequest;
import sorisoop.soridam.api.auth.presentation.request.oauth.OidcLoginRequest;
import sorisoop.soridam.auth.jwt.application.JwtService;
import sorisoop.soridam.auth.jwt.response.JwtResponse;
import sorisoop.soridam.auth.oauth.google.GoogleOidcService;
import sorisoop.soridam.auth.oauth.kakao.KakaoOidcService;
import sorisoop.soridam.domain.user.domain.User;

@Service
@RequiredArgsConstructor
public class AuthFacade {
private final KakaoOidcService kakaoOidcService;
private final GoogleOidcService googleOidcService;
private final JwtService jwtService;

public JwtResponse jwtLogin(JwtLoginRequest request) {
return jwtService.jwtLogin(request.email(), request.password());
}

@Transactional
public JwtResponse kakaoLogin(OidcLoginRequest idToken) {
User user = kakaoOidcService.processLogin(idToken.idToken());
user.updateLastLoginTime();
return jwtService.getToken(user);
}

@Transactional
public JwtResponse googleLogin(OidcLoginRequest idToken) {
User user = googleOidcService.processLogin(idToken.idToken());
user.updateLastLoginTime();
return jwtService.getToken(user);
}

public JwtResponse reissue(RefreshTokenRequest request) {
return jwtService.reissue(request.refreshToken());
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import sorisoop.soridam.api.auth.application.AuthService;
import sorisoop.soridam.api.auth.application.AuthFacade;
import sorisoop.soridam.api.auth.presentation.request.jwt.JwtLoginRequest;
import sorisoop.soridam.api.auth.presentation.response.jwt.JwtResponse;
import sorisoop.soridam.auth.oauth.request.OidcLoginRequest;
import sorisoop.soridam.api.auth.presentation.request.jwt.RefreshTokenRequest;
import sorisoop.soridam.auth.jwt.response.JwtResponse;
import sorisoop.soridam.api.auth.presentation.request.oauth.OidcLoginRequest;

@RestController
@RequiredArgsConstructor
@Tag(name = "Auth", description = "로그인 API")
@RequestMapping("/api/auth")
public class AuthApiController {
private final AuthService authService;
private final AuthFacade authFacade;

@Operation(summary = "JWT 로그인 API", description = """
- Description : 이 API는 로그인 시 JWT를 발급합니다.
Expand All @@ -33,27 +34,37 @@ public ResponseEntity<JwtResponse> login(
@RequestBody
JwtLoginRequest request
) {
JwtResponse response = authService.jwtLogin(request);
JwtResponse response = authFacade.jwtLogin(request);
return ResponseEntity.ok(response);
}

@PostMapping("/oauth2/kakao")
public ResponseEntity<JwtResponse> kakaoSocialLogin(
@Valid
@RequestBody
OidcLoginRequest idToken
OidcLoginRequest request
){
JwtResponse response = authService.kakaoLogin(idToken);
JwtResponse response = authFacade.kakaoLogin(request);
return ResponseEntity.ok(response);
}

@PostMapping("/oauth2/google")
public ResponseEntity<JwtResponse> googleSocialLogin(
@Valid
@RequestBody
OidcLoginRequest idToken
OidcLoginRequest request
){
JwtResponse response = authService.googleLogin(idToken);
JwtResponse response = authFacade.googleLogin(request);
return ResponseEntity.ok(response);
}

@PostMapping("/reissue")
public ResponseEntity<JwtResponse> reissue(
@Valid
@RequestBody
RefreshTokenRequest request
){
JwtResponse response = authFacade.reissue(request);
return ResponseEntity.ok(response);
}
Comment on lines +61 to 69
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

리프레시 토큰 재발행 API에 대한 문서화가 필요합니다.

다른 엔드포인트들과 같이 @Operation@ApiResponse 어노테이션을 추가하여 API 문서화를 해주세요.

다음과 같이 추가하는 것을 제안드립니다:

+    @Operation(summary = "토큰 재발행 API", description = """
+            - Description : 이 API는 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.
+        """)
+    @ApiResponse(responseCode = "200")
     @PostMapping("/reissue")
     public ResponseEntity<JwtResponse> reissue(
📝 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
@PostMapping("/reissue")
public ResponseEntity<JwtResponse> reissue(
@Valid
@RequestBody
RefreshTokenRequest request
){
JwtResponse response = authFacade.reissue(request);
return ResponseEntity.ok(response);
}
@Operation(summary = "토큰 재발행 API", description = """
- Description : API는 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.
""")
@ApiResponse(responseCode = "200")
@PostMapping("/reissue")
public ResponseEntity<JwtResponse> reissue(
@Valid
@RequestBody
RefreshTokenRequest request
){
JwtResponse response = authFacade.reissue(request);
return ResponseEntity.ok(response);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package sorisoop.soridam.api.auth.presentation.request.jwt;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;

public record RefreshTokenRequest(
@Schema(description = "refresh token",
example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsILJdhDYTYzNzQwNjQwMH0.7J",
requiredMode = REQUIRED)
Comment on lines +9 to +11
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

API 문서의 예제 토큰 값 수정 필요

API 문서의 예제 값으로 실제 JWT 형식의 토큰을 사용하는 것은 보안상 좋지 않습니다. 대신 "example-refresh-token" 같은 일반적인 문자열을 사용하는 것이 좋습니다.

 @Schema(description = "refresh token",
-    example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsILJdhDYTYzNzQwNjQwMH0.7J",
+    example = "example-refresh-token",
     requiredMode = REQUIRED)
📝 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
@Schema(description = "refresh token",
example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsILJdhDYTYzNzQwNjQwMH0.7J",
requiredMode = REQUIRED)
@Schema(description = "refresh token",
example = "example-refresh-token",
requiredMode = REQUIRED)

@NotNull
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package sorisoop.soridam.auth.oauth.request;
package sorisoop.soridam.api.auth.presentation.request.oauth;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public record NoisePersistResponse(
) {
public static NoisePersistResponse from(Noise noise){
return builder()
.id(noise.getId())
.id(noise.extractUuid())
.build();
}
}
6 changes: 6 additions & 0 deletions soridam-api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ spring:
init:
mode: always

data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}

Comment on lines +27 to +32
Copy link

Choose a reason for hiding this comment

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

💡 Codebase verification

Redis 구성 경로 불일치 및 보안 설정 개선 필요

application.yml의 Redis 구성에 다음과 같은 문제가 있습니다:

  1. 구성 경로가 RedisConfig.java와 일치하지 않습니다. Java 코드는 spring.data.redis를 참조하지만, YAML은 data.redis로 설정되어 있습니다.
  2. 프로덕션 환경을 위한 SSL/TLS 설정이 여전히 필요합니다.

다음과 같이 수정을 제안드립니다:

- data:
+ spring:
+   data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD}
+     ssl:
+       enabled: ${REDIS_SSL_ENABLED:false}
🔗 Analysis chain

Redis 구성의 보안 관련 개선사항

Redis 구성에서 다음 사항들을 고려해주시기 바랍니다:

  1. REDIS_PASSWORD 환경 변수가 설정되지 않은 경우의 처리가 없습니다. 애플리케이션 시작 시 오류가 발생할 수 있습니다.
  2. 프로덕션 환경에서는 SSL/TLS 설정이 필요할 수 있습니다.

다음과 같이 수정하는 것을 제안드립니다:

  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
-     password: ${REDIS_PASSWORD}
+     password: ${REDIS_PASSWORD:}
+     ssl:
+       enabled: ${REDIS_SSL_ENABLED:false}

환경 변수가 설정되어 있는지 확인하기 위해 다음 스크립트를 실행해보세요:

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Check if Redis-related environment variables are set in various config files

echo "Checking for Redis environment variables in configuration files..."
rg -l "REDIS_(HOST|PORT|PASSWORD)" 

Length of output: 226


Script:

#!/bin/bash
# Search for Redis-related configurations in Java code and other config files
echo "Searching for Redis configuration in Java code..."
rg -t java "Redis" -A 5

echo -e "\nSearching for SSL/TLS configurations..."
rg "ssl" -i

Length of output: 6385

springdoc:
default-consumes-media-type: application/json
default-produces-media-type: application/json
Expand Down
3 changes: 3 additions & 0 deletions soridam-auth/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ dependencies {
// 도메인 및 공통 모듈
implementation project(':soridam-common')
implementation project(':soridam-domain')
implementation project(':soridam-global-util')

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

//swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.1'

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import sorisoop.soridam.auth.common.CustomAccessDeniedHandler;
import sorisoop.soridam.auth.jwt.JwtAuthenticationEntryPoint;
import sorisoop.soridam.auth.jwt.JwtAuthenticationFilter;
import sorisoop.soridam.auth.jwt.JwtProvider;
import sorisoop.soridam.auth.jwt.application.JwtProvider;

@Configuration
@EnableWebSecurity
Expand Down Expand Up @@ -51,7 +51,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(STATIC_RESOURCES_PATTERNS).permitAll()
.requestMatchers(PERMIT_ALL_PATTERNS).permitAll()
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(OAUTH2_PATTERNS).permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exceptions -> exceptions
Expand Down Expand Up @@ -84,12 +83,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/api/auth/**",
};

private static final String[] OAUTH2_PATTERNS = {
"/oauth2/**", // Spring Security OAuth2 기본 경로
"/login/oauth2/**", // 로그인 리다이렉트 처리 경로
"/oauth2/authorization/**" // 인증 요청 트리거 경로
};


CorsConfigurationSource corsConfigurationSource() {
return request -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import sorisoop.soridam.auth.jwt.application.JwtProvider;
import sorisoop.soridam.common.exception.CustomException;
import sorisoop.soridam.common.exception.ExceptionResponse;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package sorisoop.soridam.auth.jwt;
package sorisoop.soridam.auth.jwt.application;

import java.time.Duration;
import java.util.Collections;
Expand All @@ -19,12 +19,14 @@
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import lombok.RequiredArgsConstructor;
import sorisoop.soridam.auth.jwt.JwtProperties;
import sorisoop.soridam.auth.jwt.exception.JwtExpiredException;
import sorisoop.soridam.auth.jwt.exception.JwtInvalidException;
import sorisoop.soridam.auth.jwt.exception.JwtMalformedException;
import sorisoop.soridam.auth.jwt.exception.JwtSignatureInvalidException;
import sorisoop.soridam.auth.jwt.exception.JwtUnsupportedException;
import sorisoop.soridam.domain.user.domain.Role;
import sorisoop.soridam.domain.user.exception.UnauthorizedException;

@Service
@RequiredArgsConstructor
Expand All @@ -33,9 +35,19 @@ public class JwtProvider {

private final Header header = Jwts.header().type("JWT").build();

public String generateToken(String userId, Role role, Duration expiredAt) {
public String generateAccessToken(String userId, Role role, Duration duration) {
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), userId, role);
return makeToken(new Date(now.getTime() + duration.toMillis()), userId, role);
}

public String generateAccessToken(String userId, Role role) {
Date now = new Date();
return makeToken(new Date(now.getTime() + Duration.ofDays(2).toMillis()), userId, role);
}

public String generateRefreshToken(String userId, Role role) {
Date now = new Date();
return makeToken(new Date(now.getTime() + Duration.ofDays(7).toMillis()), userId, role);
}
minjo-on marked this conversation as resolved.
Show resolved Hide resolved

private String makeToken(Date expiry, String userId, Role role) {
Expand Down Expand Up @@ -96,7 +108,7 @@ public Set<SimpleGrantedAuthority> getRoles(Claims claims) {
case "ADMIN" -> Collections.singleton(new SimpleGrantedAuthority("ROLE_ADMIN"));
case "PAID_USER" -> Collections.singleton(new SimpleGrantedAuthority("ROLE_PAID_USER"));
case "USER" -> Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
default -> Collections.emptySet();
default -> throw new UnauthorizedException();
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package sorisoop.soridam.auth.jwt.application;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import sorisoop.soridam.auth.jwt.response.JwtResponse;
import sorisoop.soridam.domain.refresh.RefreshToken;
import sorisoop.soridam.domain.refresh.application.RefreshTokenService;
import sorisoop.soridam.domain.user.application.UserCommandService;
import sorisoop.soridam.domain.user.application.UserQueryService;
import sorisoop.soridam.domain.user.domain.User;

@Service
@RequiredArgsConstructor
public class JwtService {
private final UserQueryService userQueryService;
private final UserCommandService userCommandService;
private final RefreshTokenService refreshTokenService;
private final JwtProvider jwtProvider;

public JwtResponse jwtLogin(String email, String password) {
User user = userCommandService.login(email, password);
JwtResponse response = getToken(user);
refreshTokenService.save(user.getId(), response.refreshToken());
return response;
}

public JwtResponse reissue(String token) {
RefreshToken refreshToken = refreshTokenService.getToken(token);
String userId = refreshToken.getUserId();
User user = userQueryService.getById(userId);
JwtResponse response = getToken(user);

refreshTokenService.delete(refreshToken);
refreshTokenService.save(userId, response.refreshToken());

return response;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

리프레시 토큰 재발행 시 동시성 문제 가능성이 있습니다.

현재 구현에서는 이전 토큰 삭제와 새 토큰 저장이 원자적으로 실행되지 않습니다. 동시에 여러 요청이 들어올 경우 문제가 발생할 수 있습니다.

다음과 같은 개선을 제안드립니다:

 public JwtResponse reissue(String token) {
     RefreshToken refreshToken = refreshTokenService.getToken(token);
     String userId = refreshToken.getUserId();
     User user = userQueryService.getById(userId);
     JwtResponse response = getToken(user);
 
-    refreshTokenService.delete(refreshToken);
-    refreshTokenService.save(userId, response.refreshToken());
+    refreshTokenService.replaceToken(refreshToken, userId, response.refreshToken());
 
     return response;
 }

RefreshTokenService에 원자적 작업을 위한 replaceToken 메소드를 추가하는 것을 추천드립니다.

Committable suggestion skipped: line range outside the PR's diff.


public JwtResponse getToken(User user) {
String refreshToken = jwtProvider.generateRefreshToken(user.extractUuid(), user.getRole());
String accessToken = jwtProvider.generateAccessToken(user.extractUuid(), user.getRole());

return JwtResponse.of(accessToken, refreshToken);
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package sorisoop.soridam.api.auth.presentation.response.jwt;
package sorisoop.soridam.auth.jwt.response;

import lombok.Builder;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import sorisoop.soridam.auth.oauth.exception.OidcExpiredException;
import sorisoop.soridam.auth.oauth.exception.OidcInvalidAudienceException;
import sorisoop.soridam.auth.oauth.exception.OidcInvalidIssuerException;
import sorisoop.soridam.auth.oauth.request.OidcLoginRequest;
import sorisoop.soridam.common.domain.Provider;
import sorisoop.soridam.domain.user.domain.User;
import sorisoop.soridam.domain.user.domain.UserRepository;
Expand Down Expand Up @@ -44,9 +43,9 @@ private OidcIdToken validateAndDecodeIdToken(String idToken) {
return oidcIdToken;
}

public User processLogin(OidcLoginRequest request) {
OidcIdToken idToken = validateAndDecodeIdToken(request.idToken());
return findOrCreateUser(idToken);
public User processLogin(String idToken) {
OidcIdToken oidcIdToken = validateAndDecodeIdToken(idToken);
return findOrCreateUser(oidcIdToken);
}

private void validateIssuer(String issuer) {
Expand Down
1 change: 1 addition & 0 deletions soridam-domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ dependencies {

// 데이터베이스와 상호작용 (JPA 사용)
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
Loading
Loading