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

[Feature/#17] refresh token 발급 시 Redis에 저장하도록 설정 #18

Merged
merged 19 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fd9b834
feat: Redis 저장소 추가 및 세팅 (#17)
Mingyum-Kim Aug 15, 2023
2b061bd
docs: @Parameter 어노테이션으로 OAuthController 파라미터 설명 추가
Mingyum-Kim Aug 15, 2023
ef58406
refactor: 소문자 provider를 대문자로 변경하는 함수 작성
Mingyum-Kim Aug 15, 2023
16451df
feat: OAuthController에 issue 메서드 추가 (#17)
Mingyum-Kim Aug 15, 2023
ff0c4bb
refactor: socialId를 uid로, socialType을 provider로 명칭 변경
Mingyum-Kim Aug 15, 2023
0d9000f
feat: RefreshToken, RefreshTokenRepository 작성 (#17)
Mingyum-Kim Aug 15, 2023
9e5e1d4
feat: 액세스 토큰 만료 시 재발급 요청하는 reissue 서비스 코드 작성 (#17)
Mingyum-Kim Aug 16, 2023
2d277fd
feat: 로그인 시 refresh token을 redis에 저장하는 기능 추가 (#17)
Mingyum-Kim Aug 16, 2023
35962f8
refactor: Auth 비즈니스 로직 Exception 처리
Mingyum-Kim Aug 16, 2023
ed0580a
feat: Redis 설정 수정
Mingyum-Kim Aug 16, 2023
ad342b4
refactor: login 호출 시 잘못된 provider가 입력되는 경우 예외처리
Mingyum-Kim Aug 16, 2023
a0b4e2a
test: reissue, login fail 테스트 케이스 추가 (#17)
Mingyum-Kim Aug 16, 2023
4cf5396
refactor: CustomResponse를 사용하여 Controller 반환
Mingyum-Kim Aug 16, 2023
a1e2d19
feat: @Valid 어노테이션 추가
Mingyum-Kim Aug 16, 2023
6959ceb
ci: 배포 시 테스트되지 않도록 deploy.yml 수정
Mingyum-Kim Aug 16, 2023
a4fee28
build: build.yml에 Redis 설정 추가
Mingyum-Kim Aug 16, 2023
9dd2def
build: redis port 입력 방식 수정
Mingyum-Kim Aug 16, 2023
8ca7024
refactor: 풀 리퀘스트 리뷰 반영
Mingyum-Kim Aug 16, 2023
f975196
refactor: Token ExceptionHandler로 처리
Mingyum-Kim Aug 16, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- name: Gradle Authorization
run: chmod +x gradlew
- name: Gradle Build Run
run: ./gradlew build
run: ./gradlew build -x test

- name: Create Zip files
run: zip -r ./$GITHUB_SHA.zip .
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
// database
implementation 'org.mariadb.jdbc:mariadb-java-client:3.1.4'
runtimeOnly 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build.gradle에 redis 라이브러리 추가했습니다! 로컬에서도 Redis 프로그램을 localhost:6379 환경으로 세팅해야 관련 코드가 정상적으로 동작합니다:)


// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
Expand Down
22 changes: 16 additions & 6 deletions src/main/java/com/backend/auth/application/OAuthService.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.backend.auth.application;

import com.backend.auth.jwt.TokenProvider;
import com.backend.auth.presentation.dto.response.LoginResponse;
import com.backend.auth.presentation.dto.response.AccessTokenResponse;
import com.backend.auth.presentation.dto.response.TokenResponse;
import com.backend.global.common.code.ErrorCode;
import com.backend.global.exception.BusinessException;
import com.backend.member.application.MemberService;
import com.backend.member.domain.Member;
import com.backend.member.domain.Provider;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Expand All @@ -15,10 +19,16 @@ public class OAuthService {

private final TokenProvider tokenProvider;

public LoginResponse login(String provider, String socialId) {
Member member = memberService.findMemberOrRegister(provider, socialId);
String accessToken = tokenProvider.generateAccessToken(member);
String refreshToken = tokenProvider.generateRefreshToken(member);
return new LoginResponse(accessToken, refreshToken);
public TokenResponse login(String provider, String uid) {
memberService.findMemberOrRegister(Provider.from(provider), uid);
String accessToken = tokenProvider.generateAccessToken(uid);
String refreshToken = tokenProvider.generateRefreshToken(uid);
return new TokenResponse(accessToken, refreshToken);
}

public AccessTokenResponse reissue(String refreshToken) throws Exception {
tokenProvider.validateToken(refreshToken);
String accessToken = tokenProvider.reissueAccessToken(refreshToken);
return new AccessTokenResponse(accessToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.backend.auth.application;

import com.backend.auth.domain.RefreshToken;
import com.backend.auth.domain.RefreshTokenRepository;
import com.backend.global.common.code.ErrorCode;
import com.backend.global.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class RefreshTokenService {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis에 RefreshToken을 저장, 조회하기 위한 서비스 클래스입니다!


private final RefreshTokenRepository refreshTokenRepository;

public void saveRefreshToken(String refreshToken, String uid){
refreshTokenRepository.save(new RefreshToken(refreshToken, uid));
}
public String findUidByRefreshToken(String refreshToken){
RefreshToken result = refreshTokenRepository.findById(refreshToken)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_ERROR));
return result.getUid();
}
}
28 changes: 28 additions & 0 deletions src/main/java/com/backend/auth/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.backend.auth.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.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

@Configuration
@EnableRedisRepositories
public class RedisConfig {

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

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

@Bean
public RedisConnectionFactory connectionFactory(){
RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration();
redisConfiguration.setHostName(host);
redisConfiguration.setPort(port);
return new LettuceConnectionFactory(redisConfiguration);
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/backend/auth/domain/RefreshToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.backend.auth.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

@Getter
@AllArgsConstructor
@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 14)
public class RefreshToken {
@Id
private String refreshToken;
private String uid;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.backend.auth.domain;

import org.springframework.data.repository.CrudRepository;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
}
45 changes: 26 additions & 19 deletions src/main/java/com/backend/auth/jwt/TokenProvider.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.backend.auth.jwt;

import com.backend.member.domain.Member;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import com.backend.auth.application.RefreshTokenService;
import com.backend.auth.domain.RefreshToken;
import com.backend.global.common.code.ErrorCode;
import com.backend.global.exception.BusinessException;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -20,29 +20,39 @@ public class TokenProvider {

private final Key key;

public TokenProvider(@Value("${jwt.secret}") String secretKey){
private final RefreshTokenService refreshTokenService;

public TokenProvider(@Value("${jwt.secret}") String secretKey, RefreshTokenService refreshTokenService){
this.refreshTokenService = refreshTokenService;
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}

public String generateAccessToken(Member member){
return generateToken(member, ACCESS_TOKEN_EXPIRE_TIME);
public String generateAccessToken(String uid){
return generateToken(uid, ACCESS_TOKEN_EXPIRE_TIME);
}

public String generateRefreshToken(Member member){
return generateToken(member, REFRESH_TOKEN_EXPIRE_TIME);
public String generateRefreshToken(String uid){
String refreshToken = generateToken(uid, REFRESH_TOKEN_EXPIRE_TIME);
refreshTokenService.saveRefreshToken(refreshToken, uid);
return refreshToken;
}

public String generateToken(Member member, Long expireTime){
public String generateToken(String uid, Long expireTime){
Date now = new Date();
return Jwts.builder()
.setSubject(member.getSocialId())
.setSubject(uid)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expireTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
Comment on lines +41 to 49
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

실제 JWT 생성 과정에서 uid(구 socialId)만 사용하기 때문에 uid만 필드에 받아오는 것으로 변경하였습니다.


public String reissueAccessToken(String refreshToken) {
String uid = refreshTokenService.findUidByRefreshToken(refreshToken);
return generateAccessToken(uid);
}

public String getPayload(String token){
return Jwts.parserBuilder()
.setSigningKey(key)
Expand All @@ -52,19 +62,16 @@ public String getPayload(String token){
.getSubject();
}

public boolean validateToken(String token){
public void validateToken(String token) throws Exception {
try{
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e){
throw e;
} catch (UnsupportedJwtException e){
throw e;
} catch (IllegalArgumentException e){
throw e;
throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
} catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e){
throw new Exception("잘못된 형식의 토큰입니다.");
}
}
}
38 changes: 26 additions & 12 deletions src/main/java/com/backend/auth/presentation/OAuthController.java
Original file line number Diff line number Diff line change
@@ -1,31 +1,45 @@
package com.backend.auth.presentation;

import com.backend.auth.application.OAuthService;
import com.backend.auth.presentation.dto.response.LoginResponse;
import com.backend.auth.presentation.dto.TokenReissueRequest;
import com.backend.auth.presentation.dto.response.AccessTokenResponse;
import com.backend.auth.presentation.dto.response.TokenResponse;
import com.backend.global.common.code.SuccessCode;
import com.backend.global.common.response.CustomResponse;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import static com.backend.global.common.code.SuccessCode.*;

@Tag(name = "회원 인증", description = "소셜 로그인 API입니다.")
@RequiredArgsConstructor
@RestController
public class OAuthController {

private final OAuthService oauthService;

@Operation(summary = "소셜 로그인", description = "소셜 로그인 후 사용자 토큰 발급")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "소셜 로그인 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청으로 인한 실패"),
@ApiResponse(responseCode = "401", description = "접근 권한 없음")
})
@Operation(summary = "소셜 로그인",
description = "카카오, 애플 서버에서 로그인한 사용자의 userId를 통해 access token과 refresh token을 반환합니다.")
@PostMapping("/auth/{provider}")
public ResponseEntity<LoginResponse> generateAccessToken(@PathVariable String provider, @RequestParam String userId) {
return ResponseEntity.ok(oauthService.login(provider, userId));
public ResponseEntity<CustomResponse> generateAccessTokenAndRefreshToken(
@Parameter(description = "kakao, apple 중 현재 로그인하는 소셜 타입", in = ParameterIn.PATH) @PathVariable String provider,
@Parameter(description = "사용자 ID") @RequestParam String userId) {
return CustomResponse.success(LOGIN_SUCCESS, oauthService.login(provider, userId));
}

}
@Operation(summary = "토큰 재발급",
description = "access token 만료 시 refresh token을 통해 access token을 재발급합니다.")
@PostMapping("/reissue")
@ExceptionHandler({UnsupportedJwtException.class, MalformedJwtException.class, IllegalArgumentException.class})
public ResponseEntity<CustomResponse> reissue(@Valid @RequestBody TokenReissueRequest reissueRequest) throws Exception {
return CustomResponse.success(LOGIN_SUCCESS, oauthService.reissue(reissueRequest.refreshToken()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.backend.auth.presentation.dto;

import jakarta.validation.constraints.NotNull;

public record TokenReissueRequest(
@NotNull(message = "access token reissue 시 refresh token 입력은 필수입니다.")
String refreshToken
) { }
Comment on lines +5 to +8
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

궁금한 부분이 있는데요, reissue를 하는 과정에서 그럼 발생할 수 있는 문제가

  1. 토큰을 못 찾아서 발생하는 not found 커스텀 에러
  2. 토큰을 아예 안넘겨서 발생하는 binding 에러
  3. 토큰 validation 과정에서 발생하는 에러
    이렇게 발생할 것 같은데, 프론트에는 뭐든 에러가 발생하면 재로그인 로직으로 넘어가도록 인계하는 걸까요?!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드에 문제가 있는게 아니라 궁금증 입니다😊

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. RefreshTokenService의 find 함수에서 처리하였습니다 !
  2. Dto의 @NotNull 어노테이션을 이용한 검증 이외의 추가 validation이 필요할까요 ??
  3. 그 부분은 한 번 찾아봐야 할 것 같습니다 !! 객체를 반환하는 것 이외에 redirect하는 방식을 말씀하신걸까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 넵넵! 확인했습니다~
  2. 더 필요하지 않을것 같아요! 사실 notnull의 필요성도 그리 크지 않다고 생각이 되요!!
  3. 음..이부분은 서버에서는 딱히 신경 안써도 될것 같긴해요!! 리프레시 API를 프론트가 호출하고, 거기서 어떤 예외든 반환되면 로그인 화면으로 이동 시키면 되니 서버는 상관 없을것 같네요!

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.backend.auth.presentation.dto.response;

public record AccessTokenResponse (
String accessToken
){ }
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.backend.auth.presentation.dto.response;

public record LoginResponse (
public record TokenResponse(
String accessToken,
String refreshToken
){}
Expand Down
7 changes: 5 additions & 2 deletions src/main/java/com/backend/global/common/code/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.backend.global.common.code;

import lombok.Getter;
import org.springframework.http.HttpStatus;

import static org.springframework.http.HttpStatus.*;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;

@Getter
public enum ErrorCode {
Expand Down Expand Up @@ -54,9 +56,10 @@ public enum ErrorCode {
*/

/* Plan */
PLAN_NOT_FOUND(NOT_FOUND.value(), "PLAN-001", "상위 목표가 존재하지 않습니다.");

PLAN_NOT_FOUND(NOT_FOUND.value(), "PLAN-001", "상위 목표가 존재하지 않습니다."),

/* Auth */
TOKEN_EXPIRED(UNAUTHORIZED.value(), "AUTH-001", "토큰의 유효기간이 만료되었습니다.");

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 여기서는 토큰 유효기간이 만료됐다는 메세지만 만들고 나머지는 exception를 잡는 exceptionHandler에 걸려서 reason 컬럼으로 에러가 출력되도록 해도 될것 같아요! 기본 JWT 예외를 사용하는게 어떻겠냐는 부분에 대한 답도 되는것 같습니다~

private final int status;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public enum SuccessCode {
SELECT_SUCCESS(200, "SELECT SUCCESS"),
INSERT_SUCCESS(201, "INSERT SUCCESS"),
DELETE_SUCCESS(200, "DELETE SUCCESS"),
UPDATE_SUCCESS(200, "UPDATE SUCCESS");
UPDATE_SUCCESS(200, "UPDATE SUCCESS"),
LOGIN_SUCCESS(200, "LOGIN SUCCESS");

private final int status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand Down Expand Up @@ -123,7 +122,7 @@ protected ResponseEntity<ErrorResponse> handleJsonProcessingException(JsonProces
// 잡히지 않은 에러들을 일괄 처리
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception ex) {
log.error("intenalServerError", ex);
log.error("internalServerError", ex);
ErrorResponse response = ErrorResponse.of(ErrorCode.SERVER_ERROR, ex.getMessage());
return new ResponseEntity<>(response, HTTP_STATUS_OK);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.backend.member.domain.Member;
import com.backend.member.domain.MemberRepository;
import com.backend.member.domain.Provider;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Expand All @@ -13,8 +14,8 @@ public class MemberService {

private final MemberRepository memberRepository;

public Member findMemberOrRegister(String provider, String socialId) {
Optional<Member> member = memberRepository.findBySocialId(socialId);
return member.orElseGet(() -> memberRepository.save(Member.from(provider, socialId)));
public Member findMemberOrRegister(Provider provider, String uid) {
Optional<Member> member = memberRepository.findByUid(uid);
return member.orElseGet(() -> memberRepository.save(Member.from(provider, uid)));
}
}
Loading
Loading