Skip to content

Commit 031089d

Browse files
authored
Merge pull request #68 from UruruLab/feat/57-jwt-refresh-token
JWT Refresh Token 자동 갱신 및 Redis 기반 토큰 관리 기능 추가
2 parents 9151255 + 45e8485 commit 031089d

14 files changed

+342
-83
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ dependencies {
4848
// S3 (AWS SDK for Java v2)
4949
implementation 'software.amazon.awssdk:s3:2.20.74'
5050

51+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
52+
5153
// https://mvnrepository.com/artifact/org.springdoc/springdoc-openapi-starter-webmvc-ui
5254
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8")
5355
}

src/main/java/com/ururulab/ururu/UruruApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.ururulab.ururu;
22

3+
import com.ururulab.ururu.auth.jwt.JwtProperties;
34
import org.springframework.boot.SpringApplication;
45
import org.springframework.boot.autoconfigure.SpringBootApplication;
6+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
57
import org.springframework.scheduling.annotation.EnableAsync;
68

79
@EnableAsync

src/main/java/com/ururulab/ururu/auth/controller/AuthController.java

Lines changed: 23 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.ururulab.ururu.auth.dto.request.SocialLoginRequest;
44
import com.ururulab.ururu.auth.dto.response.SocialLoginResponse;
5+
import com.ururulab.ururu.auth.exception.InvalidJwtTokenException;
56
import com.ururulab.ururu.auth.jwt.JwtTokenProvider;
67
import com.ururulab.ururu.auth.service.SocialLoginService;
78
import com.ururulab.ururu.auth.service.SocialLoginServiceFactory;
@@ -10,7 +11,6 @@
1011
import jakarta.validation.Valid;
1112
import lombok.RequiredArgsConstructor;
1213
import lombok.extern.slf4j.Slf4j;
13-
import org.springframework.http.HttpStatus;
1414
import org.springframework.http.ResponseEntity;
1515
import org.springframework.web.bind.annotation.*;
1616
import org.springframework.web.servlet.view.RedirectView;
@@ -45,22 +45,15 @@ public final class AuthController {
4545
public ResponseEntity<ApiResponseFormat<SocialLoginResponse>> socialLogin(
4646
@Valid @RequestBody final SocialLoginRequest request
4747
) {
48-
try {
49-
final SocialLoginService loginService = socialLoginServiceFactory.getService(request.provider());
50-
final SocialLoginResponse loginResponse = loginService.processLogin(request.code());
48+
final SocialLoginService loginService = socialLoginServiceFactory.getService(request.provider());
49+
final SocialLoginResponse loginResponse = loginService.processLogin(request.code());
5150

52-
log.info("Social login successful for provider: {}, member: {}",
53-
request.provider(), loginResponse.memberInfo().memberId());
51+
log.info("Social login successful for provider: {}, member: {}",
52+
request.provider(), loginResponse.memberInfo().memberId());
5453

55-
return ResponseEntity.ok(
56-
ApiResponseFormat.success("소셜 로그인에 성공했습니다.", loginResponse)
57-
);
58-
59-
} catch (final Exception e) {
60-
log.error("Social login failed for provider: {}", request.provider(), e);
61-
return ResponseEntity.badRequest()
62-
.body(ApiResponseFormat.fail("소셜 로그인에 실패했습니다: " + e.getMessage()));
63-
}
54+
return ResponseEntity.ok(
55+
ApiResponseFormat.success("소셜 로그인에 성공했습니다.", loginResponse)
56+
);
6457
}
6558

6659

@@ -114,14 +107,6 @@ public RedirectView handleKakaoCallback(
114107
return redirectView;
115108
}
116109

117-
@PostMapping("/logout")
118-
public ResponseEntity<ApiResponseFormat<Void>> logout() {
119-
log.info("Member logout requested");
120-
return ResponseEntity.ok(
121-
ApiResponseFormat.success("로그아웃되었습니다.")
122-
);
123-
}
124-
125110
@GetMapping("/status")
126111
public ResponseEntity<ApiResponseFormat<AuthStatusResponse>> getAuthStatus(
127112
@RequestHeader(value = "Authorization", required = false) final String authorization) {
@@ -133,26 +118,19 @@ public ResponseEntity<ApiResponseFormat<AuthStatusResponse>> getAuthStatus(
133118
);
134119
}
135120

136-
try {
137-
final String token = authorization.substring(7);
138-
final Long memberId = jwtTokenProvider.getMemberId(token);
139-
final String email = jwtTokenProvider.getEmail(token);
140-
final String role = jwtTokenProvider.getRole(token);
141-
142-
if (jwtTokenProvider.validateToken(token)) {
143-
return ResponseEntity.ok(
144-
ApiResponseFormat.success("인증된 상태입니다.",
145-
AuthStatusResponse.authenticated(memberId, email, role))
146-
);
147-
} else {
148-
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
149-
.body(ApiResponseFormat.fail("토큰이 유효하지 않습니다."));
150-
}
151-
} catch (final Exception e) {
152-
log.warn("Token validation failed: {}", e.getMessage());
153-
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
154-
.body(ApiResponseFormat.fail("토큰 검증에 실패했습니다."));
121+
final String token = authorization.substring(7);
122+
final Long memberId = jwtTokenProvider.getMemberId(token);
123+
final String email = jwtTokenProvider.getEmail(token);
124+
final String role = jwtTokenProvider.getRole(token);
125+
126+
if (!jwtTokenProvider.validateToken(token)) {
127+
throw new InvalidJwtTokenException("토큰이 유효하지 않습니다.");
155128
}
129+
130+
return ResponseEntity.ok(
131+
ApiResponseFormat.success("인증된 상태입니다.",
132+
AuthStatusResponse.authenticated(memberId, email, role))
133+
);
156134
}
157135

158136
private String generateSecureState() {
@@ -162,14 +140,9 @@ private String generateSecureState() {
162140
}
163141

164142
private String generateAuthUrl(final SocialProvider provider) {
165-
try {
166-
final SocialLoginService loginService = socialLoginServiceFactory.getService(provider);
167-
final String state = generateSecureState();
168-
return loginService.getAuthorizationUrl(state);
169-
} catch (final Exception e) {
170-
log.warn("Failed to generate auth URL for provider: {}", provider, e);
171-
return "";
172-
}
143+
final SocialLoginService loginService = socialLoginServiceFactory.getService(provider);
144+
final String state = generateSecureState();
145+
return loginService.getAuthorizationUrl(state);
173146
}
174147

175148
private String maskSensitiveData(final String data) {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.ururulab.ururu.auth.controller;
2+
3+
import com.ururulab.ururu.auth.dto.request.RefreshTokenRequest;
4+
import com.ururulab.ururu.auth.dto.response.SocialLoginResponse;
5+
import com.ururulab.ururu.auth.exception.MissingAuthorizationHeaderException;
6+
import com.ururulab.ururu.auth.exception.RedisConnectionException;
7+
import com.ururulab.ururu.auth.service.JwtRefreshService;
8+
import com.ururulab.ururu.auth.jwt.JwtTokenProvider;
9+
import com.ururulab.ururu.global.common.dto.ApiResponse;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.data.redis.RedisConnectionFailureException;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
@Slf4j
18+
@RestController
19+
@RequestMapping("/auth")
20+
@RequiredArgsConstructor
21+
public final class JwtRefreshController {
22+
private final JwtRefreshService jwtRefreshService;
23+
private final JwtTokenProvider jwtTokenProvider;
24+
25+
@PostMapping("/refresh")
26+
public ResponseEntity<ApiResponse<SocialLoginResponse>> refreshToken(
27+
@Valid @RequestBody RefreshTokenRequest request
28+
) {
29+
final String newAccessToken = jwtRefreshService.refreshAccessToken(request.refreshToken());
30+
final String refreshToken = request.refreshToken();
31+
final Long expiresIn = jwtTokenProvider.getAccessTokenExpiry();
32+
final Long memberId = jwtTokenProvider.getMemberId(newAccessToken);
33+
final String email = jwtTokenProvider.getEmail(newAccessToken);
34+
final String role = jwtTokenProvider.getRole(newAccessToken);
35+
36+
final SocialLoginResponse responseBody = SocialLoginResponse.of(
37+
newAccessToken,
38+
refreshToken,
39+
expiresIn,
40+
SocialLoginResponse.MemberInfo.of(memberId, email, null, null)
41+
);
42+
return ResponseEntity.ok(ApiResponse.success("토큰이 갱신되었습니다.", responseBody));
43+
}
44+
45+
@PostMapping("/logout")
46+
public ResponseEntity<ApiResponse<Void>> logout(
47+
@RequestHeader("Authorization") String authorization
48+
) {
49+
if (authorization == null || !authorization.startsWith("Bearer ")) {
50+
throw new MissingAuthorizationHeaderException("Authorization 헤더가 필요합니다.");
51+
}
52+
53+
final String accessToken = authorization.substring(7);
54+
final Long memberId = jwtTokenProvider.getMemberId(accessToken);
55+
56+
try {
57+
jwtRefreshService.logout(memberId, accessToken);
58+
} catch (RedisConnectionFailureException e) {
59+
throw new RedisConnectionException("일시적인 서버 오류입니다.", e);
60+
}
61+
62+
return ResponseEntity.ok(ApiResponse.success("로그아웃되었습니다."));
63+
}
64+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.ururulab.ururu.auth.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
/**
6+
* 토큰 갱신 요청 DTO.
7+
* 클라이언트로부터 받은 Refresh Token을 담는다.
8+
*/
9+
public record RefreshTokenRequest(
10+
@NotBlank(message = "리프레시 토큰은 필수입니다")
11+
String refreshToken
12+
) {
13+
public static RefreshTokenRequest of(final String refreshToken) {
14+
return new RefreshTokenRequest(refreshToken);
15+
}
16+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.ururulab.ururu.auth.exception;
2+
3+
/**
4+
* JWT 토큰이 유효하지 않을 때 발생하는 예외.
5+
*/
6+
public final class InvalidJwtTokenException extends RuntimeException {
7+
public InvalidJwtTokenException(final String message) {
8+
super(message);
9+
}
10+
11+
public InvalidJwtTokenException(final String message, final Throwable cause) {
12+
super(message, cause);
13+
}
14+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.ururulab.ururu.auth.exception;
2+
3+
/**
4+
* Refresh 토큰이 유효하지 않을 때 발생하는 예외.
5+
* (만료, 위조, 미인증 로그아웃 등의 경우)
6+
*/
7+
public final class InvalidRefreshTokenException extends RuntimeException {
8+
public InvalidRefreshTokenException(final String message) {
9+
super(message);
10+
}
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.ururulab.ururu.auth.exception;
2+
3+
/**
4+
* Authorization 헤더가 누락되었을 때 발생하는 예외.
5+
*/
6+
public final class MissingAuthorizationHeaderException extends RuntimeException {
7+
public MissingAuthorizationHeaderException(final String message) {
8+
super(message);
9+
}
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.ururulab.ururu.auth.exception;
2+
3+
/**
4+
* Redis 연결 실패 시 발생하는 예외.
5+
*/
6+
public final class RedisConnectionException extends RuntimeException {
7+
public RedisConnectionException(final String message) {
8+
super(message);
9+
}
10+
11+
public RedisConnectionException(final String message, final Throwable cause) {
12+
super(message, cause);
13+
}
14+
}

src/main/java/com/ururulab/ururu/auth/jwt/JwtProperties.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
public final class JwtProperties {
1616

1717
private String secret;
18-
private long accessTokenExpiry = 3600L;
19-
private long refreshTokenExpiry = 1209600L;
20-
private String issuer = "ururu-backend";
21-
private String audience = "ururu-client";
18+
private long accessTokenExpiry;
19+
private long refreshTokenExpiry;
20+
private String issuer;
21+
private String audience;
2222
}

0 commit comments

Comments
 (0)