Skip to content

Commit db43ac5

Browse files
authored
Merge pull request #34 from prgrms-web-devcourse-final-project/feat#28
[feat] 리프레시 토큰 기반 인증 시스템 구현
2 parents ddbf06b + b6fbb3f commit db43ac5

File tree

6 files changed

+234
-67
lines changed

6 files changed

+234
-67
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.back.domain.user.controller;
2+
3+
import com.back.domain.user.service.UserAuthService;
4+
import com.back.global.rsData.RsData;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
7+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.servlet.http.HttpServletRequest;
10+
import jakarta.servlet.http.HttpServletResponse;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
@Tag(name = "UserAuth", description = "사용자 인증 API")
18+
@Slf4j
19+
@RestController
20+
@RequestMapping("/api/user/auth")
21+
@RequiredArgsConstructor
22+
public class UserAuthController {
23+
24+
private final UserAuthService userAuthService;
25+
26+
//400 Bad Request: 클라이언트가 잘못된 요청을 보냄 (형식 오류)
27+
//401 Unauthorized: 인증 실패 (토큰 없음/만료/유효하지 않음)
28+
//404 Not Found: 리소스를 찾을 수 없음
29+
@Operation(summary = "토큰 갱신", description = "리프레시 토큰으로 새로운 액세스 토큰을 발급")
30+
@ApiResponses(value = {
31+
@ApiResponse(responseCode = "200", description = "토큰 갱신 성공"),
32+
@ApiResponse(responseCode = "401", description = "토큰이 유효하지 않거나 만료됨")
33+
})
34+
@PostMapping("/refresh")
35+
public RsData<Void> refreshToken(HttpServletRequest request, HttpServletResponse response) {
36+
boolean success = userAuthService.refreshTokens(request, response);
37+
38+
if (success) {
39+
return RsData.of(200, "토큰이 성공적으로 갱신되었습니다.");
40+
} else {
41+
return RsData.of(401, "토큰 갱신에 실패했습니다. 다시 로그인해주세요.");
42+
}
43+
}
44+
45+
@Operation(summary = "로그아웃", description = "현재 세션을 종료하고 토큰을 무효화")
46+
@ApiResponse(responseCode = "200", description = "로그아웃 성공")
47+
@PostMapping("/logout")
48+
public RsData<Void> logout(HttpServletRequest request, HttpServletResponse response) {
49+
userAuthService.logout(request, response);
50+
return RsData.of(200, "로그아웃되었습니다.");
51+
}
52+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.back.domain.user.service;
2+
3+
import com.back.domain.user.entity.User;
4+
import com.back.domain.user.repository.UserRepository;
5+
import com.back.global.exception.ServiceException;
6+
import com.back.global.jwt.JwtUtil;
7+
import com.back.global.jwt.refreshToken.entity.RefreshToken;
8+
import com.back.global.jwt.refreshToken.repository.RefreshTokenRepository;
9+
import com.back.global.jwt.refreshToken.service.RefreshTokenService;
10+
import com.back.global.rsData.RsData;
11+
import jakarta.servlet.http.HttpServletRequest;
12+
import jakarta.servlet.http.HttpServletResponse;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.stereotype.Service;
16+
import org.springframework.transaction.annotation.Transactional;
17+
18+
import java.time.LocalDateTime;
19+
import java.util.Optional;
20+
21+
@Slf4j
22+
@Service
23+
@RequiredArgsConstructor
24+
public class UserAuthService {
25+
26+
private final JwtUtil jwtUtil;
27+
private final UserRepository userRepository;
28+
private final RefreshTokenService refreshTokenService;
29+
private final RefreshTokenRepository refreshTokenRepository;
30+
31+
//OAuth 관련
32+
33+
public User joinSocial(String oauthId, String email, String nickname){
34+
userRepository.findByOauthId(oauthId)
35+
.ifPresent(user -> {
36+
throw new ServiceException(409, "이미 존재하는 계정입니다.");
37+
});
38+
39+
// 고유한 닉네임 생성
40+
String uniqueNickname = generateUniqueNickname(nickname);
41+
42+
User user = User.builder()
43+
.email(email)
44+
.nickname(uniqueNickname)
45+
.abvDegree(0.0)
46+
.createdAt(LocalDateTime.now())
47+
.updatedAt(LocalDateTime.now())
48+
.role("USER")
49+
.oauthId(oauthId)
50+
.build();
51+
52+
return userRepository.save(user);
53+
}
54+
55+
@Transactional
56+
public RsData<User> findOrCreateOAuthUser(String oauthId, String email, String nickname) {
57+
Optional<User> existingUser = userRepository.findByOauthId(oauthId);
58+
59+
if (existingUser.isPresent()) {
60+
// 기존 사용자 업데이트 (이메일만 업데이트)
61+
User user = existingUser.get();
62+
user.setEmail(email);
63+
return RsData.of(200, "회원 정보가 업데이트 되었습니다", user); //더티체킹
64+
} else {
65+
User newUser = joinSocial(oauthId, email, nickname);
66+
return RsData.of(201, "사용자가 생성되었습니다", newUser);
67+
}
68+
}
69+
70+
public String generateUniqueNickname(String baseNickname) {
71+
// null이거나 빈 문자열인 경우 기본값 설정
72+
if (baseNickname == null || baseNickname.trim().isEmpty()) {
73+
baseNickname = "User";
74+
}
75+
76+
String nickname = baseNickname;
77+
int counter = 1;
78+
79+
// 중복 체크 및 고유한 닉네임 생성
80+
while (userRepository.findByNickname(nickname).isPresent()) {
81+
nickname = baseNickname + counter;
82+
counter++;
83+
}
84+
85+
return nickname;
86+
}
87+
88+
// 리프레시 토큰 관련
89+
90+
public void issueTokens(HttpServletResponse response, Long userId, String email) {
91+
String accessToken = jwtUtil.generateAccessToken(userId, email);
92+
String refreshToken = refreshTokenService.generateRefreshToken(userId, email);
93+
94+
jwtUtil.addAccessTokenToCookie(response, accessToken);
95+
jwtUtil.addRefreshTokenToCookie(response, refreshToken);
96+
}
97+
98+
public boolean refreshTokens(HttpServletRequest request, HttpServletResponse response) {
99+
try {
100+
String oldRefreshToken = jwtUtil.getRefreshTokenFromCookie(request);
101+
102+
if (oldRefreshToken == null || !refreshTokenService.validateToken(oldRefreshToken)) {
103+
return false;
104+
}
105+
106+
Optional<RefreshToken> tokenData = refreshTokenRepository.findByToken(oldRefreshToken);
107+
if (tokenData.isEmpty()) {
108+
return false;
109+
}
110+
111+
RefreshToken refreshTokenEntity = tokenData.get();
112+
Long userId = refreshTokenEntity.getUserId();
113+
String email = refreshTokenEntity.getEmail();
114+
115+
String newRefreshToken = refreshTokenService.rotateToken(oldRefreshToken);
116+
String newAccessToken = jwtUtil.generateAccessToken(userId, email);
117+
118+
jwtUtil.addAccessTokenToCookie(response, newAccessToken);
119+
jwtUtil.addRefreshTokenToCookie(response, newRefreshToken);
120+
121+
return true;
122+
} catch (Exception e) {
123+
log.error("토큰 갱신 중 오류 발생: {}", e.getMessage());
124+
return false;
125+
}
126+
}
127+
128+
//토큰 끊기면서 OAuth 자동 로그아웃
129+
public void logout(HttpServletRequest request, HttpServletResponse response) {
130+
String refreshToken = jwtUtil.getRefreshTokenFromCookie(request);
131+
132+
if (refreshToken != null) {
133+
refreshTokenService.revokeToken(refreshToken);
134+
}
135+
136+
jwtUtil.removeAccessTokenCookie(response);
137+
jwtUtil.removeRefreshTokenCookie(response);
138+
}
139+
}

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

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,10 @@
22

33
import com.back.domain.user.entity.User;
44
import com.back.domain.user.repository.UserRepository;
5-
import com.back.global.exception.ServiceException;
6-
import com.back.global.rsData.RsData;
75
import lombok.RequiredArgsConstructor;
86
import org.springframework.stereotype.Service;
97
import org.springframework.transaction.annotation.Transactional;
108

11-
import java.time.LocalDateTime;
12-
import java.util.Optional;
13-
149
@Service
1510
@RequiredArgsConstructor
1611
public class UserService {
@@ -24,59 +19,6 @@ public User findById(Long id) {
2419
}
2520

2621

27-
public User joinSocial(String oauthId, String email, String nickname){
28-
userRepository.findByOauthId(oauthId)
29-
.ifPresent(user -> {
30-
throw new ServiceException(409, "이미 존재하는 계정입니다.");
31-
});
32-
33-
// 고유한 닉네임 생성
34-
String uniqueNickname = generateUniqueNickname(nickname);
35-
36-
User user = User.builder()
37-
.email(email)
38-
.nickname(uniqueNickname)
39-
.abvDegree(0.0)
40-
.createdAt(LocalDateTime.now())
41-
.updatedAt(LocalDateTime.now())
42-
.role("USER")
43-
.oauthId(oauthId)
44-
.build();
45-
46-
return userRepository.save(user);
47-
}
48-
49-
@Transactional
50-
public RsData<User> findOrCreateOAuthUser(String oauthId, String email, String nickname) {
51-
Optional<User> existingUser = userRepository.findByOauthId(oauthId);
52-
53-
if (existingUser.isPresent()) {
54-
// 기존 사용자 업데이트 (이메일만 업데이트)
55-
User user = existingUser.get();
56-
user.setEmail(email);
57-
return RsData.of(200, "회원 정보가 업데이트 되었습니다", user); //더티체킹
58-
} else {
59-
User newUser = joinSocial(oauthId, email, nickname);
60-
return RsData.of(201, "사용자가 생성되었습니다", newUser);
61-
}
62-
}
63-
64-
public String generateUniqueNickname(String baseNickname) {
65-
// null이거나 빈 문자열인 경우 기본값 설정
66-
if (baseNickname == null || baseNickname.trim().isEmpty()) {
67-
baseNickname = "User";
68-
}
69-
70-
String nickname = baseNickname;
71-
int counter = 1;
72-
73-
// 중복 체크 및 고유한 닉네임 생성
74-
while (userRepository.findByNickname(nickname).isPresent()) {
75-
nickname = baseNickname + counter;
76-
counter++;
77-
}
7822

79-
return nickname;
80-
}
8123

8224
}

src/main/java/com/back/global/jwt/JwtUtil.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public void removeAccessTokenCookie(HttpServletResponse response) {
7171
response.addCookie(cookie);
7272
}
7373

74-
public boolean validateToken(String token) {
74+
public boolean validateAccessToken(String token) {
7575
try {
7676
Jwts.parser()
7777
.verifyWith(secretKey)
@@ -110,4 +110,35 @@ private Claims parseToken(String token) {
110110
.getPayload();
111111
}
112112

113+
public void addRefreshTokenToCookie(HttpServletResponse response, String refreshToken) {
114+
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken);
115+
cookie.setHttpOnly(true);
116+
cookie.setSecure(false);
117+
cookie.setPath("/");
118+
cookie.setMaxAge(60 * 60 * 24 * 30);
119+
response.addCookie(cookie);
120+
}
121+
122+
public String getRefreshTokenFromCookie(HttpServletRequest request) {
123+
Cookie[] cookies = request.getCookies();
124+
if (cookies != null) {
125+
for (Cookie cookie : cookies) {
126+
if (REFRESH_TOKEN_COOKIE_NAME.equals(cookie.getName())) {
127+
return cookie.getValue();
128+
}
129+
}
130+
}
131+
return null;
132+
}
133+
134+
public void removeRefreshTokenCookie(HttpServletResponse response) {
135+
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, null);
136+
cookie.setHttpOnly(true);
137+
cookie.setSecure(false);
138+
cookie.setPath("/");
139+
cookie.setMaxAge(0);
140+
response.addCookie(cookie);
141+
}
142+
143+
113144
}

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import jakarta.servlet.http.HttpServletRequest;
1212
import jakarta.servlet.http.HttpServletResponse;
1313
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.beans.factory.annotation.Value;
1416
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
1517
import org.springframework.security.core.Authentication;
1618
import org.springframework.security.core.context.SecurityContextHolder;
@@ -21,16 +23,18 @@
2123
import java.io.IOException;
2224
import java.util.Map;
2325

26+
@Slf4j
2427
@Component
2528
@RequiredArgsConstructor
2629
public class CustomAuthenticationFilter extends OncePerRequestFilter {
2730
private final JwtUtil jwtUtil;
2831
private final Rq rq;
2932

33+
@Value("${custom.accessToken.expirationSeconds}")
34+
private int accessTokenExpiration;
35+
3036
@Override
3137
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
32-
logger.debug("Processing request for " + request.getRequestURI());
33-
3438
try {
3539
work(request, response, filterChain);
3640
} catch (ServiceException e) {
@@ -90,7 +94,7 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt
9094

9195
// accessToken 검증
9296
if (isAccessTokenExists) {
93-
if (jwtUtil.validateToken(accessToken)) {
97+
if (jwtUtil.validateAccessToken(accessToken)) {
9498
Long userId = jwtUtil.getUserIdFromToken(accessToken);
9599
String email = jwtUtil.getEmailFromToken(accessToken);
96100
String nickname = jwtUtil.getNicknameFromToken(accessToken);
@@ -113,7 +117,7 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt
113117
// accessToken이 만료됐으면 새로 발급
114118
if (isAccessTokenExists && !isAccessTokenValid) {
115119
String newAccessToken = jwtUtil.generateAccessToken(user.getId(), user.getEmail());
116-
rq.setCrossDomainCookie("accessToken", newAccessToken, 60 * 20);
120+
rq.setCrossDomainCookie("accessToken", newAccessToken, accessTokenExpiration);
117121
}
118122

119123
// SecurityContext에 인증 정보 저장

0 commit comments

Comments
 (0)