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

Redis를 이용한 엑세스 토큰 재발급 기능 구현 #30

Merged
merged 11 commits into from
Nov 23, 2024
Merged
Show file tree
Hide file tree
Changes from all 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,24 @@
package com.kyonggi.teampu.domain.auth.controller;

import com.kyonggi.teampu.domain.auth.service.ReissueService;
import com.kyonggi.teampu.global.response.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class ReissueController {
private final ReissueService reissueService;
@PostMapping("/reissue")
ApiResponse reissue(HttpServletRequest request, HttpServletResponse response){
String newAccess = reissueService.createNewAccessToken(request, response);
response.addHeader("Authorization", "Bearer " + newAccess);

return ApiResponse.ok();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,17 @@
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class CustomMemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;

@Override
public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
Optional<Member> member = memberRepository.findByLoginId(loginId);

if (member.isEmpty()) {
throw new UsernameNotFoundException("해당 유저를 찾을 수 없습니다: " + loginId);
//UserDetails에 담아서 return하면 AutneticationManager가 검증 함
}

System.out.println("**************Found user***************");
System.out.println(" loginId : " + member.get().getLoginId());
System.out.println(" password : " + member.get().getPassword());
System.out.println("***************************************");
//UserDetails에 담아서 return하면 AutneticationManager가 검증 함
Member member = memberRepository.findByLoginId(loginId)
.orElseThrow(() -> new UsernameNotFoundException("해당 유저를 찾을 수 없습니다: " + loginId));

return new CustomMemberDetails(member.get());
return new CustomMemberDetails(member);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.kyonggi.teampu.domain.auth.service;

import com.kyonggi.teampu.domain.auth.domain.RefreshToken;
import com.kyonggi.teampu.domain.auth.repository.RefreshTokenRepository;
import com.kyonggi.teampu.global.util.JwtUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;

import java.util.Arrays;

import static com.kyonggi.teampu.global.exception.ErrorCode.*;

@Service
@RequiredArgsConstructor
public class ReissueService {
private final JwtUtil jwtUtil;
private final RefreshTokenRepository refreshTokenRepository;

public String createNewAccessToken(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = getRefreshTokenFromCookies(request);
String loginId = validateAndGetLoginId(refreshToken);

refreshTokenRepository.deleteById(refreshToken);
String newAccessJwt = jwtUtil.createJwt("access", loginId, 30 * 60 * 1000L);
String newRefreshJwt = jwtUtil.createJwt("refresh", "fakeLoginId", 24 * 60 * 60 * 1000L);
RefreshToken newRefreshToken = new RefreshToken(newRefreshJwt, loginId);
refreshTokenRepository.save(newRefreshToken);

response.addHeader("Set-Cookie", createCookie("refresh", newRefreshJwt).toString());

return newAccessJwt;
}

private String getRefreshTokenFromCookies(HttpServletRequest request) {
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals("refresh"))
.map(Cookie::getValue)
.findAny()
.orElseThrow(() -> new IllegalArgumentException(NOT_FOUND_REFRESH_TOKEN.getMessage()));
}

private String validateAndGetLoginId(String refreshToken) {
if (!jwtUtil.validateToken(refreshToken)) {
throw new IllegalStateException(INVALID_REFRESH_TOKEN.getMessage());
}
if (jwtUtil.isExpired(refreshToken)) {
throw new IllegalStateException(EXPIRED_REFRESH_TOKEN.getMessage());
}
if (!jwtUtil.getCategory(refreshToken).equals("refresh")) {
throw new IllegalStateException(INVALID_REFRESH_TOKEN.getMessage());
}

RefreshToken refreshTokenEntity = refreshTokenRepository.findById(refreshToken)
.orElseThrow(() -> new IllegalStateException(INVALID_REFRESH_TOKEN.getMessage()));

return refreshTokenEntity.getLoginId();
}

private ResponseCookie createCookie(String key, String value) {
return ResponseCookie.from(key, value)
.path("/") //쿠키 경로 설정(=도메인 내 모든경로)
.sameSite("None") //sameSite 설정 (크롬에서 사용하려면 해당 설정이 필요함)
.httpOnly(false) //JS에서 쿠키 접근 가능하도록함
.secure(true) // HTTPS 연결에서만 쿠키 사용 sameSite 설정시 필요
.maxAge(24 * 60 * 60)// 쿠키 유효기간 설정 (=refresh 토큰 만료주기)
.build();
}

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.kyonggi.teampu.domain.myPage.service;

import com.kyonggi.teampu.domain.member.domain.Member;
import com.kyonggi.teampu.domain.member.repository.MemberRepository;
import com.kyonggi.teampu.domain.myPage.dto.MyPageRequest;
import com.kyonggi.teampu.domain.myPage.dto.MyPageResponse.MyPageDTO;
import com.kyonggi.teampu.domain.myPage.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/com/kyonggi/teampu/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.kyonggi.teampu.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

@Value("${spring.data.redis.port}")
private int port;

@Value("${spring.data.redis.host}")
private String host;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}

@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());

return redisTemplate;
}
}
23 changes: 13 additions & 10 deletions src/main/java/com/kyonggi/teampu/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//CORS 설정
http.cors((cors) -> cors
.configurationSource(request -> {
CorsConfiguration configuration = new CorsConfiguration();
Expand All @@ -70,24 +69,28 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.httpBasic(AbstractHttpConfigurer::disable);

http.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_URLS).permitAll()
.anyRequest().authenticated());
.requestMatchers(PUBLIC_URLS).permitAll()
.anyRequest().authenticated());

http.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(customEntryPoint));

//소셜 로그인시 무한 루프 문제 해결을 위해 인가 검증필터는 로그인 필터 이후에 삽입

http.addFilterAfter(new JwtFilter(jwtUtil, memberRepository), UsernamePasswordAuthenticationFilter.class)
//인증 필터 자리에 커스텀한 로그인 필터 삽입
.addFilterAt(
new LoginFilter(authenticationManager(authenticationConfiguration), refreshTokenRepository, jwtUtil,
memberRepository, "/api/login"),
UsernamePasswordAuthenticationFilter.class)
//로그아웃 전에 커스텀한 로그아웃 필터 적용
new LoginFilter(
authenticationManager(authenticationConfiguration),
refreshTokenRepository,
jwtUtil,
memberRepository,
"/api/login"),
//소셜 로그인시 무한 루프 문제 해결을 위해 인가 검증필터는 로그인 필터 이후에 삽입
UsernamePasswordAuthenticationFilter.class
)
.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshTokenRepository), LogoutFilter.class);

http.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kyonggi.teampu.global.exception.ErrorCode;
import com.kyonggi.teampu.global.response.ApiResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -14,7 +13,7 @@

import java.io.IOException;

import static com.kyonggi.teampu.global.exception.ErrorCode.*;
import static com.kyonggi.teampu.global.exception.ErrorCode.MEMBER_NOT_AUTHENTICATED;

@Slf4j
@Component
Expand All @@ -24,7 +23,7 @@ public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e
) throws IOException, ServletException {
) throws IOException {
createAPIResponse(response, MEMBER_NOT_AUTHENTICATED);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.kyonggi.teampu.global.exception.ErrorCode;
import com.kyonggi.teampu.global.response.ApiResponse;
import com.kyonggi.teampu.global.util.JwtUtil;
import com.sun.jdi.request.InvalidRequestStateException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
Expand All @@ -20,6 +19,7 @@
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;
import java.util.Arrays;
import java.util.Optional;

import static com.kyonggi.teampu.global.exception.ErrorCode.*;
Expand All @@ -43,81 +43,63 @@ private void doFilter(
HttpServletResponse response,
FilterChain filterChain
) throws IOException, ServletException {

//요청 url이 "/api/logout" 이 아닌 경우 다음 필터로 넘김
String requestUri = request.getRequestURI();
if (!requestUri.matches("^\\/api/logout$")) {
filterChain.doFilter(request, response);
return;
}
// 요청 메서드가 POST 가 아닌 경우 다음 필터로 넘김
String requestMethod = request.getMethod();
if (!requestMethod.equals("POST")) {
filterChain.doFilter(request, response);
return;
}
//토큰 추출
String refreshToken = getRefreshTokenFromCookies(request);
//토큰 검증 실패시 다음 필터로 넘김
if (!isTokenValid(refreshToken, response)) {
return;
}
//Redis에 저장되어있는 토큰 삭제
refreshTokenRepository.deleteById(refreshToken);

//Refresh 토큰 Cookie 값 0
refreshTokenRepository.deleteById(refreshToken);
Cookie cookie = new Cookie("refresh", null);
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setHttpOnly(false);
response.addCookie(cookie);

//API 응답 생성 및 다음 필터로 넘기지 않음
createAPIResponse(response);
createSuccessResponse(response);
}

private String getRefreshTokenFromCookies(HttpServletRequest request) {
// 쿠키에서 리프레쉬 토큰을 찾아옴
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
// 찾을 수 없으면 예외처리
if (refresh == null) {
throw new InvalidRequestStateException("쿠키에 refresh token 을 찾아올 수 없습니다");
}
return refresh;
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals("refresh"))
.map(Cookie::getValue)
.findAny()
.orElseThrow(() -> new IllegalArgumentException(NOT_FOUND_REFRESH_TOKEN.getMessage()));
}

private boolean isTokenValid(String refreshToken, HttpServletResponse response) throws IOException {
if (!jwtUtil.validateToken(refreshToken)) {
createAPIResponse(response, INVALID_REFRESH_TOKEN);
createExceptionResponse(response, INVALID_REFRESH_TOKEN);
return false;
}
// refresh 토큰 만료 시 예외처리
if (jwtUtil.isExpired(refreshToken)) {
createAPIResponse(response, EXPIRED_REFRESH_TOKEN);
createExceptionResponse(response, EXPIRED_REFRESH_TOKEN);
return false;
}
// 페이로드에 refresh 토큰이 아니면 예외처리 (ex access token)
String category = jwtUtil.getCategory(refreshToken);
if (!category.equals("refresh")) {
createAPIResponse(response, INVALID_REFRESH_TOKEN);
if (!jwtUtil.getCategory(refreshToken).equals("refresh")) {
createExceptionResponse(response, INVALID_REFRESH_TOKEN);
return false;
}
// 해당 토큰이 Redis에 저장되어 있지 않으면 예외처리

Optional<RefreshToken> refreshTokenEntity = refreshTokenRepository.findById(refreshToken);
if (refreshTokenEntity.isEmpty()) {
createAPIResponse(response, INVALID_REFRESH_TOKEN);
createExceptionResponse(response, INVALID_REFRESH_TOKEN);
return false;
}

return true;
// 위 상황 처럼 refresh token 에 문제가 있으면 로그아웃을 못하나요??
// -> refresh 토큰에 문제가 있으면 access 토큰 재발급 자체가 안됨 (=이미 로그아웃된 상태)
}

private void createAPIResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
private void createExceptionResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
ApiResponse<Object> apiResponse = ApiResponse.exception(errorCode);
response.setStatus(errorCode.getHttpStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Expand All @@ -126,7 +108,7 @@ private void createAPIResponse(HttpServletResponse response, ErrorCode errorCode
mapper.writeValue(response.getWriter(), apiResponse);
}

private void createAPIResponse(HttpServletResponse response) throws IOException {
private void createSuccessResponse(HttpServletResponse response) throws IOException {
ApiResponse<Object> apiResponse = ApiResponse.ok();
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Expand Down
Loading
Loading