Skip to content

Commit

Permalink
feat: refreshtoken 추가 - #92
Browse files Browse the repository at this point in the history
  • Loading branch information
jher235 authored Jul 28, 2024
2 parents 1a1328b + 52d1097 commit 5aa9793
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 9 deletions.
1 change: 1 addition & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,6 @@ jobs: # 실행할 작업들에 대한 설정
sudo docker pull ${{secrets.DOCKERHUB_USERNAME}}/${{secrets.DOCKERHUB_IMAGE}}
sudo docker ps -q | xargs -r sudo docker stop
sudo docker ps -aq | xargs -r sudo docker rm
sudo docker run --name redis --rm -d -p 6379:6379 redis
sudo docker run --name ${{secrets.DOCKERHUB_IMAGE}} --rm -d -p 8080:8080 ${{secrets.DOCKERHUB_USERNAME}}/${{secrets.DOCKERHUB_IMAGE}}
sudo docker system prune -f
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ dependencies {
implementation 'javax.xml.bind:jaxb-api:2.3.1'//JAXB

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'//AWS

implementation 'org.springframework.boot:spring-boot-starter-data-redis'//Redis
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class AuthenticationConfig implements WebMvcConfigurer {
private final AuthenticatedUserArgumentResolver authenticatedUserArgumentResolver;

private static final String[] ADD_PATH_PATTERNS = {"/fridge/**","/recipes/**","/auth/leave","/auth/logout", "/auth/likes", "/users/me/**"};
private static final String[] EXCLUDE_PATH_PATTERNS = {"/auth/signin", "/auth/login"};
private static final String[] EXCLUDE_PATH_PATTERNS = {"/auth/signin", "/auth/login", "/auth/refresh"};

//인터셉터 등록 + 경로 설정
@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.hackathonteam1.refreshrator.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.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisKeyValueAdapter;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

private final String redisHost;
private final int redisPort;

public RedisConfig(@Value("${spring.data.redis.host}") String redisHost,
@Value("${spring.data.redis.port}")int redisPort){
this.redisHost = redisHost;
this.redisPort = redisPort;
}

@Bean
public RedisConnectionFactory redisConnectionFactory(){
return new LettuceConnectionFactory(redisHost, redisPort);
}

@Bean
public <K, V> RedisTemplate<K, V> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<K, V> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer()); //key를 string으로 시리얼라이즈
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); //value를 json으로 시리얼라이즈
redisTemplate.setHashKeySerializer(new StringRedisSerializer()); //Hash의 key를 String으로 시리얼라이즈
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); //Hash의 value를 json으로 시리얼라이즈
return redisTemplate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.hackathonteam1.refreshrator.dto.response.recipe.RecipeListDto;
import com.hackathonteam1.refreshrator.entity.User;
import com.hackathonteam1.refreshrator.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
Expand Down Expand Up @@ -49,6 +50,15 @@ public ResponseEntity<ResponseDto<Void>> leave(@AuthenticatedUser User user,Http
.build();
response.addHeader("set-cookie", cookie.toString());

ResponseCookie cookie_refresh = ResponseCookie.from("RefreshToken",null)
.maxAge(0)
.path("/auth/refresh")
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();
response.addHeader("set-cookie", cookie_refresh.toString());

return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, "회원 탈퇴 완료"), HttpStatus.OK);
}

Expand All @@ -67,6 +77,16 @@ public ResponseEntity<ResponseDto<Void>> login(@RequestBody @Valid LoginDto logi
.build();
response.addHeader("set-cookie", cookie.toString());

ResponseCookie cookie_refresh = ResponseCookie.from("RefreshToken",tokenResponseDto.getRefreshToken().getTokenId().toString())
.maxAge(Duration.ofDays(14))
.path("/auth/refresh")
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();

response.addHeader("set-cookie", cookie_refresh.toString());

return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, "로그인 완료"), HttpStatus.OK);
}

Expand All @@ -81,6 +101,15 @@ public ResponseEntity<ResponseDto<Void>> logout(@AuthenticatedUser User user, fi
.build();
response.addHeader("set-cookie", cookie.toString());

ResponseCookie cookie_refresh = ResponseCookie.from("RefreshToken",null)
.maxAge(0)
.path("/auth/refresh")
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();
response.addHeader("set-cookie", cookie_refresh.toString());

return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, "로그아웃 완료"), HttpStatus.OK);
}

Expand All @@ -92,4 +121,29 @@ public ResponseEntity<ResponseDto<RecipeListDto>> showAllRecipeLikes(@Authentica
RecipeListDto recipeListDto = authService.showAllRecipeLikes(user, page, size);
return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, "좋아요 누른 레시피 목록 조회 성공", recipeListDto), HttpStatus.OK);
}

@GetMapping("/refresh")
public ResponseEntity<ResponseDto<Void>> refresh(HttpServletRequest request, HttpServletResponse response){
TokenResponseDto tokenResponseDto = authService.refresh(request);
String bearerToken = JwtEncoder.encodeJwtToken(tokenResponseDto.getAccessToken());

ResponseCookie cookie_access = ResponseCookie.from(AuthenticationExtractor.TOKEN_COOKIE_NAME, bearerToken)
.maxAge(Duration.ofMillis(1800000))
.path("/")
.httpOnly(true)
.sameSite("None").secure(true)
.build();

ResponseCookie cookie_refresh = ResponseCookie.from("RefreshToken", tokenResponseDto.getRefreshToken().getTokenId().toString())
.maxAge(Duration.ofDays(14))
.path("/auth/refresh")
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();

response.addHeader("set-cookie", cookie_access.toString());
response.addHeader("set-cookie", cookie_refresh.toString());
return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, "토큰 재발급 성공"), HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package com.hackathonteam1.refreshrator.dto.response.auth;

import com.hackathonteam1.refreshrator.entity.RefreshToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TokenResponseDto {
private String AccessToken;
private RefreshToken refreshToken;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.hackathonteam1.refreshrator.entity;


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.index.Indexed;

import java.util.UUID;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class RefreshToken {
@Id
private UUID tokenId;
@Indexed
private UUID userId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.hackathonteam1.refreshrator.exception;

import com.hackathonteam1.refreshrator.exception.errorcode.ErrorCode;

public class RedisException extends CustomException{
public RedisException(ErrorCode errorCode, String detail) {
super(errorCode, detail);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public enum ErrorCode {
RECIPE_IMAGE_CONFLICT("4093", "레시피에 이미지가 이미 존재합니다."),

//InternetException
FILE_STORAGE_ERROR("5000", "파일을 업로드할 수 없습니다.");
FILE_STORAGE_ERROR("5000", "파일을 업로드할 수 없습니다."),
REDIS_ERROR("5001", "Redis에서 오류가 발생했습니다.");

private final String code;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,52 @@
package com.hackathonteam1.refreshrator.service;

import com.hackathonteam1.refreshrator.authentication.JwtEncoder;
import com.hackathonteam1.refreshrator.authentication.JwtTokenProvider;
import com.hackathonteam1.refreshrator.authentication.PasswordHashEncryption;
import com.hackathonteam1.refreshrator.dto.request.auth.LoginDto;
import com.hackathonteam1.refreshrator.dto.request.auth.SigninDto;
import com.hackathonteam1.refreshrator.dto.response.auth.TokenResponseDto;
import com.hackathonteam1.refreshrator.dto.response.recipe.RecipeDto;
import com.hackathonteam1.refreshrator.dto.response.recipe.RecipeListDto;
import com.hackathonteam1.refreshrator.entity.Fridge;
import com.hackathonteam1.refreshrator.entity.Recipe;
import com.hackathonteam1.refreshrator.entity.RecipeLike;
import com.hackathonteam1.refreshrator.entity.User;
import com.hackathonteam1.refreshrator.entity.*;
import com.hackathonteam1.refreshrator.exception.ConflictException;
import com.hackathonteam1.refreshrator.exception.ForbiddenException;
import com.hackathonteam1.refreshrator.exception.NotFoundException;
import com.hackathonteam1.refreshrator.exception.UnauthorizedException;
import com.hackathonteam1.refreshrator.exception.errorcode.ErrorCode;
import com.hackathonteam1.refreshrator.repository.FridgeRepository;
import com.hackathonteam1.refreshrator.repository.RecipeLikeRepository;
import com.hackathonteam1.refreshrator.repository.UserRepository;
import com.hackathonteam1.refreshrator.util.RedisUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Service
@AllArgsConstructor
@Slf4j
public class AuthService {
private final UserRepository userRepository;
private final FridgeRepository fridgeRepository;
private final PasswordHashEncryption passwordHashEncryption;
private final JwtTokenProvider jwtTokenProvider;
private final RecipeLikeRepository recipeLikeRepository;

private final RedisUtil<String, RefreshToken> redisUtilForRefreshToken;
private final RedisUtil<String, String> redisUtilForUserId;

private final static int TIMEOUT = 14;
private final static TimeUnit TIME_UNIT = TimeUnit.DAYS;


//회원가입
public void signin(SigninDto signinDto){

Expand Down Expand Up @@ -88,7 +100,23 @@ public TokenResponseDto login(LoginDto loginDto){
String payload = String.valueOf(user.getId());
String accessToken = jwtTokenProvider.createToken(payload);

return new TokenResponseDto(accessToken);
//기존에 refreshToken이 있었는지 확인 후 삭제
Optional<String> refreshTokenId = redisUtilForUserId.findById(user.getId().toString());
if(refreshTokenId.isPresent()){
redisUtilForRefreshToken.delete(refreshTokenId.get());
redisUtilForUserId.delete(user.getId().toString());
}

UUID newRefreshTokenId = UUID.randomUUID();
RefreshToken refreshToken = RefreshToken.builder()
.tokenId(newRefreshTokenId)
.userId(user.getId())
.build();

redisUtilForRefreshToken.save(newRefreshTokenId.toString(), refreshToken, TIMEOUT, TIME_UNIT);
redisUtilForUserId.save(user.getId().toString(), newRefreshTokenId.toString(),TIMEOUT,TIME_UNIT);

return new TokenResponseDto(accessToken, refreshToken);
}

// 좋아요 누른 레시피 목록 조회
Expand All @@ -109,6 +137,44 @@ public RecipeListDto showAllRecipeLikes(User user, int page, int size) {
return recipeListDto;
}

@Transactional
public TokenResponseDto refresh(HttpServletRequest request){

Cookie[] cookies = request.getCookies();

if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("RefreshToken")) {
RefreshToken refreshToken = findRefreshTokenByRefreshTokenId(UUID.fromString(cookie.getValue()));
UUID userId = refreshToken.getUserId();
String accessToken = jwtTokenProvider.createToken(userId.toString());

//refreshToken Rotation을 위해 매번 재발급.
redisUtilForRefreshToken.delete(refreshToken.getTokenId().toString());
redisUtilForUserId.delete(userId.toString());

UUID newRefreshTokenId = UUID.randomUUID();

RefreshToken newRefreshToken = RefreshToken.builder()
.tokenId(newRefreshTokenId)
.userId(userId)
.build();

redisUtilForRefreshToken.save(newRefreshTokenId.toString(), newRefreshToken, TIMEOUT, TIME_UNIT);
redisUtilForUserId.save(userId.toString(), newRefreshTokenId.toString(),TIMEOUT,TIME_UNIT);

return new TokenResponseDto(accessToken, newRefreshToken);
}
}
}
throw new UnauthorizedException(ErrorCode.COOKIE_NOT_FOUND, "RefreshToken이 존재하지 않습니다.");
}

private RefreshToken findRefreshTokenByRefreshTokenId(UUID tokenId){
return redisUtilForRefreshToken.findById(tokenId.toString()).orElseThrow( () ->
new UnauthorizedException(ErrorCode.INVALID_TOKEN, "유효하지 않은 RefreshToken입니다."));
}

private <T> void checkValidPage(Page<T> pages, int page){
if(pages.getTotalPages() <= page && page != 0){
throw new NotFoundException(ErrorCode.PAGE_NOT_FOUND);
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/com/hackathonteam1/refreshrator/util/RedisUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.hackathonteam1.refreshrator.util;


import com.hackathonteam1.refreshrator.exception.RedisException;
import com.hackathonteam1.refreshrator.exception.errorcode.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Optional;
import java.util.concurrent.TimeUnit;

@Service
@AllArgsConstructor
@Slf4j
public class RedisUtil<K, V> {
private final RedisTemplate<K, V> redisTemplate;

public void save(K key, V value, long timeout, TimeUnit timeUnit) {
try {
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
} catch (Exception e) {
throw new RedisException( ErrorCode.REDIS_ERROR, e.getMessage());
}
}

public void delete(K key) {
try {
redisTemplate.delete(key);
}catch (Exception e){
throw new RedisException( ErrorCode.REDIS_ERROR, e.getMessage());
}
}

public Optional<V> findById(K key){
V result = redisTemplate.opsForValue().get(key);
return Optional.ofNullable(result);
}
}

0 comments on commit 5aa9793

Please sign in to comment.