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

4th Assignment: Redis로 RefreshToken 발급 및 관리 #14

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
9 changes: 6 additions & 3 deletions seminar/week3/practice/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,21 @@ dependencies {
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

//JWT
// JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'


//Security
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'

//Multipart file
// Multipart file
implementation("software.amazon.awssdk:bom:2.21.0")
implementation("software.amazon.awssdk:s3:2.21.0")

// Redis
implementation("org.springframework.boot:spring-boot-starter-data-redis:2.3.1.RELEASE")
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class SecurityConfig {
private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;

private static final String[] AUTH_WHITE_LIST = {"/api/v1/member"};
private static final String[] AUTH_WHITE_LIST = {"/api/v1/auth/**"};

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.sopt.practice.auth.redis.domain;

import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;

@RedisHash(value = "", timeToLive = 60 * 50 * 24 * 1000L * 14)
@AllArgsConstructor
@Getter
@Builder
public class Token {

@Id
private Long id;

@Indexed
private String refreshToken;

public static Token of(final Long id, final String refreshToken) {
return Token.builder()
.id(id)
.refreshToken(refreshToken)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.sopt.practice.auth.redis.repository;

import java.util.Optional;
import org.sopt.practice.auth.redis.domain.Token;
import org.sopt.practice.common.ErrorMessage;
import org.sopt.practice.exception.NotFoundException;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface RedisTokenRepository extends CrudRepository<Token, Long> {

default Token findByRefreshTokenOrElseThrow(final String refreshToken) {
return findByRefreshToken(refreshToken).orElseThrow(() -> new NotFoundException(ErrorMessage.REFRESH_TOKEN_NOT_FOUND));
}

default Token findByIdOrElseThrow(final Long id) {
return findById(id).orElseThrow(() -> new NotFoundException(ErrorMessage.REFRESH_TOKEN_NOT_FOUND));
}

Optional<Token> findByRefreshToken(final String refreshToken);
Optional<Token> findById(final Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@
@Getter
public enum ErrorMessage {
// 400 BAD REQUEST
LONGER_THAN_MAX_LENGTH(HttpStatus.BAD_REQUEST.value(), "longer than max length"),
LONGER_THAN_MAX_LENGTH(HttpStatus.BAD_REQUEST.value(), "최대 길이보다 깁니다."),

// 401 UNAUTHORIZED
JWT_UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "사용자의 로그인 검증을 실패했습니다."),

// 404 NOT FOUND
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "member not found"),
BLOG_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "blog not found"),
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당하는 사용자가 없습니다."),
BLOG_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당하는 블로그가 없습니다."),
REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "해당하는 리프레시 토큰이 없습니다."),
;

private int status;
private String message;
private final int status;
private final String message;

ErrorMessage(final int status, final String message) {
this.status = status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final String USER_ID = "userId";
private static final String BEARER_PREFIX = "Bearer ";

private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L * 14;
private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L * 7;
private static final Long REFRESH_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L * 14;

@Value("${jwt.secret}")
private String JWT_SECRET;
Expand All @@ -32,7 +34,11 @@ public class JwtTokenProvider {
* - authorities: null
* */
public String issueAccessToken(final Authentication authentication) {
return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME);
return generateAccessToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME);
}

public String issueRefreshToken() {
return generateRefreshToken(REFRESH_TOKEN_EXPIRATION_TIME);
}

/* 토큰 생성 로직
Expand All @@ -45,21 +51,33 @@ public String issueAccessToken(final Authentication authentication) {
- signWith: 서명 설정 및 암호화
- compact: 토큰 생성
*/
public String generateToken(Authentication authentication, Long tokenExpirationTime) {
public String generateAccessToken(Authentication authentication, Long tokenExpirationTime) {
final Date now = new Date();
final Claims claims = Jwts.claims()
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenExpirationTime)); // 만료 시간

claims.put(USER_ID, authentication.getPrincipal());

return Jwts.builder()
return BEARER_PREFIX + Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header
.setClaims(claims) // Claim
.signWith(getSigningKey()) // Signature
.compact();
}

public String generateRefreshToken(Long tokenExpirationTime) {
final Date now = new Date();
final Claims claims = Jwts.claims()
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenExpirationTime)); // 만료 시간

return BEARER_PREFIX + Jwts.builder()
.setClaims(claims)
.signWith(getSigningKey())
.compact();
}

/* 서명 생성
* Base64 인코딩된 JWT_SECRET을 SecretKey로 변환
* */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.sopt.practice.controller;

import lombok.RequiredArgsConstructor;
import org.sopt.practice.service.AuthService;
import org.sopt.practice.service.dto.MemberCreateDto;
import org.sopt.practice.service.dto.UserJoinResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthController {

private final AuthService authService;

@PostMapping
public ResponseEntity<UserJoinResponse> signUp(
@RequestBody MemberCreateDto memberCreate
) {
UserJoinResponse userJoinResponse = authService.signUp(memberCreate);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", userJoinResponse.userId())
.body(
userJoinResponse
);
}

@PostMapping("/reissue")
public ResponseEntity<UserJoinResponse> signIn(
@RequestHeader("X-Refresh-Token") final String refreshToken
) {
UserJoinResponse userJoinResponse = authService.signIn(refreshToken);
return ResponseEntity.ok(userJoinResponse);
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
package org.sopt.practice.controller;

import java.net.URI;
import lombok.RequiredArgsConstructor;
import org.sopt.practice.service.MemberService;
import org.sopt.practice.service.dto.MemberCreateDto;
import org.sopt.practice.service.dto.MemberFindDto;
import org.sopt.practice.service.dto.UserJoinResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -23,18 +17,6 @@ public class MemberController {

private final MemberService memberService;

@PostMapping
public ResponseEntity<UserJoinResponse> postMember(
@RequestBody MemberCreateDto memberCreate
) {
UserJoinResponse userJoinResponse = memberService.createMember(memberCreate);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", userJoinResponse.userId())
.body(
userJoinResponse
);
}

@GetMapping("/{memberId}")
public ResponseEntity<MemberFindDto> findMemberById(
@PathVariable final Long memberId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.sopt.practice.service;

import lombok.RequiredArgsConstructor;
import org.sopt.practice.auth.UserAuthentication;
import org.sopt.practice.auth.redis.domain.Token;
import org.sopt.practice.auth.redis.repository.RedisTokenRepository;
import org.sopt.practice.common.jwt.JwtTokenProvider;
import org.sopt.practice.domain.Member;
import org.sopt.practice.repository.MemberRepository;
import org.sopt.practice.service.dto.MemberCreateDto;
import org.sopt.practice.service.dto.UserJoinResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AuthService {

private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
private final RedisTokenRepository redisTokenRepository;

@Transactional
public UserJoinResponse signUp(
MemberCreateDto memberCreate
) {
Member member = memberRepository.save(
Member.create(memberCreate.name(), memberCreate.part(), memberCreate.age())
);
Long memberId = member.getId();
String accessToken = jwtTokenProvider.issueAccessToken(
UserAuthentication.createUserAuthentication(memberId)
);
String refreshToken = jwtTokenProvider.issueRefreshToken();
redisTokenRepository.save(Token.of(memberId, refreshToken));

return UserJoinResponse.of(accessToken, refreshToken, memberId.toString());
}

@Transactional
public UserJoinResponse signIn(
String refreshToken
) {
Token redisToken = redisTokenRepository.findByRefreshTokenOrElseThrow(refreshToken);
String newAccessToken = jwtTokenProvider.issueAccessToken(
UserAuthentication.createUserAuthentication(redisToken.getId())
);

return UserJoinResponse.of(newAccessToken, refreshToken, redisToken.getId().toString());
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
package org.sopt.practice.service;

import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.sopt.practice.auth.UserAuthentication;
import org.sopt.practice.common.ErrorMessage;
import org.sopt.practice.common.jwt.JwtTokenProvider;
import org.sopt.practice.domain.Member;
import org.sopt.practice.exception.NotFoundException;
import org.sopt.practice.repository.MemberRepository;
import org.sopt.practice.service.dto.MemberCreateDto;
import org.sopt.practice.service.dto.MemberFindDto;
import org.sopt.practice.service.dto.UserJoinResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -19,22 +14,6 @@
public class MemberService {

private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;


@Transactional
public UserJoinResponse createMember(
MemberCreateDto memberCreate
) {
Member member = memberRepository.save(
Member.create(memberCreate.name(), memberCreate.part(), memberCreate.age())
);
Long memberId = member.getId();
String accessToken = jwtTokenProvider.issueAccessToken(
UserAuthentication.createUserAuthentication(memberId)
);
return UserJoinResponse.of(accessToken, memberId.toString());
}

/* private -> protected로 다른 서비스 레이어에서 호출할 수 있도록 수정 */
protected Member findMemberById(final Long memberId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package org.sopt.practice.service.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;

public record UserJoinResponse(
String accessToken,
String refreshToken,
@JsonIgnore
String userId
) {

public static UserJoinResponse of(
String accessToken,
String refreshToken,
String userId
) {
return new UserJoinResponse(accessToken, userId);
return new UserJoinResponse(accessToken, refreshToken, userId);
}
}