Skip to content

Commit d290ff0

Browse files
authored
Merge pull request #282 from prgrms-web-devcourse-final-project/refactor#269
[refactor] 자동 로그아웃 기능 구현을 위한 토큰 리팩토링#269
2 parents e806b9d + f0972bd commit d290ff0

File tree

9 files changed

+138
-37
lines changed

9 files changed

+138
-37
lines changed

cookies.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

src/main/java/com/back/domain/user/controller/UserAuthController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.domain.user.controller;
22

33
import com.back.domain.user.dto.RefreshTokenResDto;
4+
import com.back.domain.user.dto.UserMeResDto;
45
import com.back.domain.user.service.UserAuthService;
56
import com.back.global.rsData.RsData;
67
import io.swagger.v3.oas.annotations.Operation;
@@ -11,6 +12,7 @@
1112
import jakarta.servlet.http.HttpServletResponse;
1213
import lombok.RequiredArgsConstructor;
1314
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.web.bind.annotation.GetMapping;
1416
import org.springframework.web.bind.annotation.PostMapping;
1517
import org.springframework.web.bind.annotation.RequestMapping;
1618
import org.springframework.web.bind.annotation.RestController;
@@ -50,4 +52,16 @@ public RsData<Void> logout(HttpServletRequest request, HttpServletResponse respo
5052
userAuthService.logout(request, response);
5153
return RsData.of(200, "로그아웃되었습니다.");
5254
}
55+
56+
@Operation(summary = "현재 로그인한 유저 정보 조회", description = "세션 유효성 검증 및 사용자 정보 반환")
57+
@ApiResponses(value = {
58+
@ApiResponse(responseCode = "200", description = "인증된 유저 정보 반환 성공"),
59+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"),
60+
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
61+
})
62+
@GetMapping("/me")
63+
public RsData<UserMeResDto> getCurrentUser() {
64+
UserMeResDto userInfo = userAuthService.getCurrentUser();
65+
return RsData.of(200, "인증된 유저 정보 반환 성공", userInfo);
66+
}
5367
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.back.domain.user.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
@Getter
8+
@Builder
9+
public class UserMeResDto {
10+
@JsonProperty("user")
11+
private final UserInfo user;
12+
13+
@Getter
14+
@Builder
15+
public static class UserInfo {
16+
private final String id;
17+
private final String email;
18+
private final String nickname;
19+
20+
@JsonProperty("is_first_login")
21+
private final Boolean isFirstLogin;
22+
23+
@JsonProperty("abv_degree")
24+
private final Double abvDegree;
25+
26+
private final String provider;
27+
}
28+
}

src/main/java/com/back/domain/user/service/UserAuthService.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package com.back.domain.user.service;
22

33
import com.back.domain.user.dto.RefreshTokenResDto;
4+
import com.back.domain.user.dto.UserMeResDto;
45
import com.back.domain.user.entity.User;
56
import com.back.domain.user.repository.UserRepository;
67
import com.back.global.exception.ServiceException;
78
import com.back.global.jwt.JwtUtil;
89
import com.back.global.jwt.refreshToken.entity.RefreshToken;
910
import com.back.global.jwt.refreshToken.repository.RefreshTokenRepository;
1011
import com.back.global.jwt.refreshToken.service.RefreshTokenService;
12+
import com.back.global.rq.Rq;
1113
import com.back.global.rsData.RsData;
1214
import jakarta.servlet.http.HttpServletRequest;
1315
import jakarta.servlet.http.HttpServletResponse;
@@ -76,6 +78,7 @@ public class UserAuthService {
7678
private final UserRepository userRepository;
7779
private final RefreshTokenService refreshTokenService;
7880
private final RefreshTokenRepository refreshTokenRepository;
81+
private final Rq rq;
7982

8083
//OAuth 관련
8184

@@ -211,4 +214,50 @@ public void setFirstLoginFalse(Long id) {
211214
Optional<User> userOpt = userRepository.findById(id);
212215
userOpt.ifPresent(user -> user.setFirstLogin(false));
213216
}
217+
218+
// 현재 로그인한 사용자 정보 조회 (세션 검증용)
219+
public UserMeResDto getCurrentUser() {
220+
try {
221+
User actor = rq.getActor();
222+
223+
if (actor == null) {
224+
log.debug("인증되지 않은 사용자");
225+
throw new ServiceException(401, "인증되지 않은 사용자");
226+
}
227+
228+
Optional<User> userOpt = userRepository.findById(actor.getId());
229+
if (userOpt.isEmpty()) {
230+
log.warn("사용자 ID {}를 DB에서 찾을 수 없음 (토큰은 유효하나 사용자 삭제됨)", actor.getId());
231+
throw new ServiceException(401, "인증되지 않은 사용자");
232+
}
233+
234+
User user = userOpt.get();
235+
String provider = extractProvider(user.getOauthId());
236+
237+
return UserMeResDto.builder()
238+
.user(UserMeResDto.UserInfo.builder()
239+
.id(user.getId().toString())
240+
.email(user.getEmail())
241+
.nickname(user.getNickname())
242+
.isFirstLogin(user.isFirstLogin())
243+
.abvDegree(user.getAbvDegree())
244+
.provider(provider)
245+
.build())
246+
.build();
247+
248+
} catch (ServiceException e) {
249+
throw e;
250+
} catch (Exception e) {
251+
log.error("사용자 정보 조회 중 서버 오류 발생: {}", e.getMessage(), e);
252+
throw new ServiceException(500, "서버 내부 오류");
253+
}
254+
}
255+
256+
private String extractProvider(String oauthId) {
257+
if (oauthId == null || oauthId.isBlank()) {
258+
return "unknown";
259+
}
260+
String[] parts = oauthId.split("_", 2);
261+
return parts.length > 0 ? parts[0] : "unknown";
262+
}
214263
}

src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
import jakarta.persistence.*;
44
import lombok.*;
5+
import org.springframework.data.annotation.CreatedDate;
6+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
57

68
import java.time.LocalDateTime;
79

810
@Entity
11+
@EntityListeners(AuditingEntityListener.class)
912
@Table(name = "refresh_tokens")
1013
@Getter
1114
@Setter
@@ -20,24 +23,33 @@ public class RefreshToken {
2023
@Column(nullable = false)
2124
private Long userId;
2225

23-
@Column(nullable = false)
26+
@CreatedDate
27+
@Column(nullable = false, updatable = false)
2428
private LocalDateTime createdAt;
2529

2630
@Column(nullable = false)
2731
private LocalDateTime expiresAt;
2832

33+
@Column(nullable = false)
34+
private LocalDateTime lastUsedAt;
2935

3036
public static RefreshToken create(String token, Long userId, long ttlSeconds) {
3137
LocalDateTime now = LocalDateTime.now();
3238
return RefreshToken.builder()
3339
.token(token)
3440
.userId(userId)
35-
.createdAt(now)
41+
.lastUsedAt(now)
3642
.expiresAt(now.plusSeconds(ttlSeconds))
3743
.build();
3844
}
3945

4046
public boolean isExpired() {
4147
return LocalDateTime.now().isAfter(this.expiresAt);
4248
}
49+
50+
public boolean isIdleExpired(long idleTimeoutHours) {
51+
return LocalDateTime.now().isAfter(this.lastUsedAt.plusMinutes(idleTimeoutHours));
52+
53+
}
54+
4355
}

src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ public class RefreshTokenService {
2424
@Value("${custom.refreshToken.expirationSeconds}")
2525
private long refreshTokenExpiration;
2626

27+
@Value("${custom.refreshToken.idleTimeoutHours}")
28+
private long idleTimeoutHours;
29+
2730
// 기존 리프레시 토큰 삭제하고 생성
2831
@Transactional
2932
public String generateRefreshToken(Long userId) {
@@ -37,19 +40,32 @@ public String generateRefreshToken(Long userId) {
3740
return token;
3841
}
3942

40-
//검증 (만료 체크 포함)
43+
//검증 (만료 체크 및 Idle Timeout 체크 포함)
44+
@Transactional
4145
public boolean validateToken(String token) {
4246
Optional<RefreshToken> tokenOpt = refreshTokenRepository.findByToken(token);
4347
if (tokenOpt.isEmpty()) {
4448
return false;
4549
}
4650

4751
RefreshToken refreshToken = tokenOpt.get();
52+
53+
// 1. 만료 체크 (30일)
4854
if (refreshToken.isExpired()) {
4955
revokeToken(token); // 만료된 토큰 삭제
5056
return false;
5157
}
5258

59+
// 2. Idle Timeout 체크 (4시간)
60+
if (refreshToken.isIdleExpired(idleTimeoutHours)) {
61+
revokeToken(token); // Idle 초과 토큰 삭제
62+
return false;
63+
}
64+
65+
// 3. lastUsedAt 갱신 (사용 시간 업데이트)
66+
refreshToken.setLastUsedAt(LocalDateTime.now());
67+
refreshTokenRepository.save(refreshToken);
68+
5369
return true;
5470
}
5571

src/main/java/com/back/global/security/CustomAuthenticationFilter.java

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -110,32 +110,8 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt
110110
log.error("Error extracting user info from token", e);
111111
}
112112
} else {
113-
log.warn("Access token validation failed");
114-
115-
// 토큰이 만료된 경우에도 정보 추출 시도 (선택적)
116-
try {
117-
Long userId = jwtUtil.getUserIdFromToken(accessToken);
118-
String email = jwtUtil.getEmailFromToken(accessToken);
119-
String nickname = jwtUtil.getNicknameFromToken(accessToken);
120-
121-
if (userId != null && email != null && nickname != null) {
122-
user = User.builder()
123-
.id(userId)
124-
.email(email)
125-
.nickname(nickname)
126-
.role("USER")
127-
.build();
128-
129-
// 새 토큰 발급 (쿠키 방식을 사용하는 경우만)
130-
if (authHeader == null) {
131-
String newAccessToken = jwtUtil.generateAccessToken(userId, email, nickname);
132-
rq.setCrossDomainCookie("accessToken", newAccessToken, accessTokenExpiration);
133-
log.info("New access token issued for user: {}", userId);
134-
}
135-
}
136-
} catch (Exception e) {
137-
log.error("Failed to extract user info from expired token", e);
138-
}
113+
log.warn("Access token validation failed - token is expired or invalid");
114+
// 만료된 토큰은 인증 실패 처리 (user는 null로 유지)
139115
}
140116

141117
// user가 null이면 인증 실패

src/main/java/com/back/global/security/SecurityConfig.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
1111
import org.springframework.security.config.http.SessionCreationPolicy;
1212
import org.springframework.security.web.SecurityFilterChain;
13+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1314
import org.springframework.web.cors.CorsConfiguration;
1415
import org.springframework.web.cors.CorsConfigurationSource;
1516
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@@ -31,15 +32,18 @@ public class SecurityConfig {
3132
private final CustomOAuth2LoginSuccessHandler oauth2SuccessHandler;
3233
private final CustomOAuth2LoginFailureHandler oauth2FailureHandler;
3334
private final CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver;
35+
private final CustomAuthenticationFilter customAuthenticationFilter;
3436

3537
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
3638
CustomOAuth2LoginSuccessHandler oauth2SuccessHandler,
3739
CustomOAuth2LoginFailureHandler oauth2FailureHandler,
38-
CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver) {
40+
CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver,
41+
CustomAuthenticationFilter customAuthenticationFilter) {
3942
this.customOAuth2UserService = customOAuth2UserService;
4043
this.oauth2SuccessHandler = oauth2SuccessHandler;
4144
this.oauth2FailureHandler = oauth2FailureHandler;
4245
this.customOAuth2AuthorizationRequestResolver = customOAuth2AuthorizationRequestResolver;
46+
this.customAuthenticationFilter = customAuthenticationFilter;
4347
}
4448

4549
@Bean
@@ -52,17 +56,19 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
5256
.maximumSessions(1)
5357
) // OAuth 인증시 필요할때만 세션 사용
5458

59+
.addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
5560
.authorizeHttpRequests(auth -> auth
56-
// 개발 편의성을 위해 모든 요청 허용
57-
.anyRequest().permitAll()
5861

62+
63+
.requestMatchers("/user/auth/logout").authenticated()
5964
/*
6065
.requestMatchers("/").permitAll()
6166
.requestMatchers("/h2-console/**").permitAll()
6267
.requestMatchers("/actuator/**").permitAll()
6368
.requestMatchers("/oauth2/**").permitAll()
6469
.requestMatchers("/login/oauth2/**").permitAll()
6570
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
71+
.requestMatchers("/user/auth/refresh").permitAll()
6672
6773
// 권한 불필요 - 조회 API
6874
.requestMatchers(GET, "/cocktails/**").permitAll()
@@ -80,6 +86,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
8086
// 나머지 모든 API는 인증 필요
8187
.anyRequest().authenticated()
8288
*/
89+
// 개발 편의성을 위해 모든 요청 허용
90+
.anyRequest().permitAll()
8391
)
8492
.formLogin(AbstractHttpConfigurer::disable)
8593
.httpBasic(AbstractHttpConfigurer::disable)

src/main/resources/application.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,11 @@ custom:
9999
jwt:
100100
secretKey: ${JWT_SECRET_KEY}
101101
accessToken:
102-
expirationSeconds: "#{60*15}"
102+
expirationSeconds: "#{60}" # 15분 곱하기
103103
refreshToken:
104104
expirationSeconds: "#{60*60*24*30}"
105+
idleTimeoutHours: "#{1}"
106+
# "#{60*6*4}"
105107

106108

107109
management:

0 commit comments

Comments
 (0)