diff --git a/sixthAssignment/build.gradle b/sixthAssignment/build.gradle index c851a6e..73e0bf8 100644 --- a/sixthAssignment/build.gradle +++ b/sixthAssignment/build.gradle @@ -27,7 +27,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' //JWT @@ -37,6 +37,7 @@ dependencies { //Security implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' //Multipart file implementation("software.amazon.awssdk:bom:2.21.0") diff --git a/sixthAssignment/src/main/java/org/sopt/common/auth/JwtAuthenticationFilter.java b/sixthAssignment/src/main/java/org/sopt/common/auth/JwtAuthenticationFilter.java index 8e33480..e765602 100644 --- a/sixthAssignment/src/main/java/org/sopt/common/auth/JwtAuthenticationFilter.java +++ b/sixthAssignment/src/main/java/org/sopt/common/auth/JwtAuthenticationFilter.java @@ -35,7 +35,7 @@ protected void doFilterInternal( final String token = getJwtFromRequest(request); if (jwtTokenProvider.validateToken(token) == VALID_JWT) { - Long memberId = jwtTokenProvider.getUserFromJwt(token); + Long memberId = jwtTokenProvider.getMemberIdFromToken(token); setAuthentication(request, memberId); } } catch (Exception exception) { diff --git a/sixthAssignment/src/main/java/org/sopt/common/auth/dto/AuthTokenSet.java b/sixthAssignment/src/main/java/org/sopt/common/auth/dto/AuthTokenSet.java new file mode 100644 index 0000000..d079be4 --- /dev/null +++ b/sixthAssignment/src/main/java/org/sopt/common/auth/dto/AuthTokenSet.java @@ -0,0 +1,16 @@ +package org.sopt.common.auth.dto; + +public record AuthTokenSet( + String accessToken, + String refreshToken +) { + + public static AuthTokenSet of(String accessToken, String refreshToken) { + return new AuthTokenSet(accessToken, refreshToken); + } + + public enum Type { + ACCESS_TOKEN, + REFRESH_TOKEN + } +} diff --git a/sixthAssignment/src/main/java/org/sopt/common/auth/jwt/JwtTokenProvider.java b/sixthAssignment/src/main/java/org/sopt/common/auth/jwt/JwtTokenProvider.java index cff8a86..0af125d 100644 --- a/sixthAssignment/src/main/java/org/sopt/common/auth/jwt/JwtTokenProvider.java +++ b/sixthAssignment/src/main/java/org/sopt/common/auth/jwt/JwtTokenProvider.java @@ -3,6 +3,9 @@ import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; +import org.sopt.common.auth.UserAuthentication; +import org.sopt.common.auth.dto.AuthTokenSet; +import org.sopt.common.auth.redis.service.TokenService; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; @@ -16,36 +19,45 @@ public class JwtTokenProvider { private static final String USER_ID = "userId"; - private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L * 14; private static final Long REFRESH_TOKEN_EXPIRATION_TIME = 24 * 60 * 60 * 1000L * 14; + private final TokenService tokenService; @Value("${jwt.secret}") private String JWT_SECRET; + public String issueToken(Long memberId, AuthTokenSet.Type type) { + Authentication authentication = UserAuthentication.createUserAuthentication(memberId); - public String issueAccessToken(final Authentication authentication) { - return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME); - } + if (type == AuthTokenSet.Type.ACCESS_TOKEN) + return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME); - public String issueRefreshToken(final Authentication authentication) { - return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME); - } + String refreshToken = generateToken(authentication, REFRESH_TOKEN_EXPIRATION_TIME); + tokenService.save(memberId, refreshToken); + return refreshToken; + } public String generateToken(Authentication authentication, Long tokenExpirationTime) { + Claims claims = generateClaims(authentication, tokenExpirationTime); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header + .setClaims(claims) // Claim + .signWith(getSigningKey()) // Signature + .compact(); + } + + private Claims generateClaims(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() - .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header - .setClaims(claims) // Claim - .signWith(getSigningKey()) // Signature - .compact(); + return claims; } private SecretKey getSigningKey() { @@ -77,7 +89,7 @@ private Claims getBody(final String token) { .getBody(); } - public Long getUserFromJwt(String token) { + public Long getMemberIdFromToken(String token) { Claims claims = getBody(token); return Long.valueOf(claims.get(USER_ID).toString()); } diff --git a/sixthAssignment/src/main/java/org/sopt/common/auth/redis/domain/Token.java b/sixthAssignment/src/main/java/org/sopt/common/auth/redis/domain/Token.java new file mode 100644 index 0000000..35fb54f --- /dev/null +++ b/sixthAssignment/src/main/java/org/sopt/common/auth/redis/domain/Token.java @@ -0,0 +1,21 @@ +package org.sopt.common.auth.redis.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@RedisHash(timeToLive = 60 * 60 * 24 * 1000L * 14) +@AllArgsConstructor +@Builder +@Getter +public class Token { + + @Id + private Long memberId; + + @Indexed + private String refreshToken; +} diff --git a/sixthAssignment/src/main/java/org/sopt/common/auth/redis/repository/TokenRepository.java b/sixthAssignment/src/main/java/org/sopt/common/auth/redis/repository/TokenRepository.java new file mode 100644 index 0000000..4b64f7d --- /dev/null +++ b/sixthAssignment/src/main/java/org/sopt/common/auth/redis/repository/TokenRepository.java @@ -0,0 +1,13 @@ +package org.sopt.common.auth.redis.repository; + +import org.sopt.common.auth.redis.domain.Token; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface TokenRepository extends CrudRepository { + + Optional findByMemberId(Long memberId); + Optional findByMemberIdAndRefreshToken(Long memberId, String refreshToken); + Optional findByRefreshToken(String refreshToken); +} diff --git a/sixthAssignment/src/main/java/org/sopt/common/auth/redis/service/TokenService.java b/sixthAssignment/src/main/java/org/sopt/common/auth/redis/service/TokenService.java new file mode 100644 index 0000000..f9b3253 --- /dev/null +++ b/sixthAssignment/src/main/java/org/sopt/common/auth/redis/service/TokenService.java @@ -0,0 +1,22 @@ +package org.sopt.common.auth.redis.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.common.auth.redis.domain.Token; +import org.sopt.common.auth.redis.repository.TokenRepository; +import org.sopt.common.exception.NotFoundException; +import org.sopt.common.exception.message.ErrorMessage; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class TokenService { + + private final TokenRepository tokenRepository; + + public void save(Long memberId, String refreshToken) { + tokenRepository.save(Token.builder() + .memberId(memberId) + .refreshToken(refreshToken) + .build()); + } +} diff --git a/sixthAssignment/src/main/java/org/sopt/common/dto/response/MemberJoinResponse.java b/sixthAssignment/src/main/java/org/sopt/common/dto/response/MemberJoinResponse.java index 787c9e3..3ba9095 100644 --- a/sixthAssignment/src/main/java/org/sopt/common/dto/response/MemberJoinResponse.java +++ b/sixthAssignment/src/main/java/org/sopt/common/dto/response/MemberJoinResponse.java @@ -1,14 +1,17 @@ package org.sopt.common.dto.response; +import org.sopt.common.auth.dto.AuthTokenSet; + public record MemberJoinResponse( String accessToken, + String refreshToken, String userId ) { public static MemberJoinResponse of( - String accessToken, + AuthTokenSet token, String userId ) { - return new MemberJoinResponse(accessToken, userId); + return new MemberJoinResponse(token.accessToken(), token.refreshToken(), userId); } } diff --git a/sixthAssignment/src/main/java/org/sopt/common/dto/response/MemberTokenRefreshResponse.java b/sixthAssignment/src/main/java/org/sopt/common/dto/response/MemberTokenRefreshResponse.java new file mode 100644 index 0000000..7da35c6 --- /dev/null +++ b/sixthAssignment/src/main/java/org/sopt/common/dto/response/MemberTokenRefreshResponse.java @@ -0,0 +1,10 @@ +package org.sopt.common.dto.response; + +public record MemberTokenRefreshResponse( + String accessToken +) { + + public static MemberTokenRefreshResponse of(String accessToken) { + return new MemberTokenRefreshResponse(accessToken); + } +} diff --git a/sixthAssignment/src/main/java/org/sopt/common/exception/message/ErrorMessage.java b/sixthAssignment/src/main/java/org/sopt/common/exception/message/ErrorMessage.java index d4ab444..c79e0f6 100644 --- a/sixthAssignment/src/main/java/org/sopt/common/exception/message/ErrorMessage.java +++ b/sixthAssignment/src/main/java/org/sopt/common/exception/message/ErrorMessage.java @@ -9,6 +9,7 @@ public enum ErrorMessage { MEMBER_NOT_FOUND_BY_ID_EXCEPTION(HttpStatus.NOT_FOUND.value(), "ID에 해당하는 사용자가 존재하지 않습니다."), + MEMBER_NOT_FOUND_BY_REFRESH_TOKEN_EXCEPTION(HttpStatus.NOT_FOUND.value(), "리프레시 토큰에 해당하는 사용자가 존재하지 않습니다."), BLOG_NOT_FOUND_BY_ID_EXCEPTION(HttpStatus.NOT_FOUND.value(), "ID에 해당하는 블로그가 존재하지 않습니다."), JWT_UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "사용자의 로그인 검증을 실패했습니다."), ; diff --git a/sixthAssignment/src/main/java/org/sopt/config/RedisConfig.java b/sixthAssignment/src/main/java/org/sopt/config/RedisConfig.java new file mode 100644 index 0000000..cdd04a8 --- /dev/null +++ b/sixthAssignment/src/main/java/org/sopt/config/RedisConfig.java @@ -0,0 +1,32 @@ +package org.sopt.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("${redis.host}") + private String host; + + @Value("${redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} diff --git a/sixthAssignment/src/main/java/org/sopt/controller/MemberController.java b/sixthAssignment/src/main/java/org/sopt/controller/MemberController.java index ef04bb3..82c61b9 100644 --- a/sixthAssignment/src/main/java/org/sopt/controller/MemberController.java +++ b/sixthAssignment/src/main/java/org/sopt/controller/MemberController.java @@ -5,11 +5,14 @@ import org.sopt.common.dto.response.MemberGetAllResponse; import org.sopt.common.dto.response.MemberGetResponse; import org.sopt.common.dto.response.MemberJoinResponse; +import org.sopt.common.dto.response.MemberTokenRefreshResponse; import org.sopt.service.MemberService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.security.Principal; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/member") @@ -28,11 +31,20 @@ public ResponseEntity postMember( .body(memberJoinResponse); } + @GetMapping("/refresh") + public ResponseEntity refreshAccessToken( + @RequestHeader("Authorization") String refreshToken + ) { + return ResponseEntity.status(HttpStatus.OK) + .body(memberService.refreshAccessToken(refreshToken)); + } + @GetMapping("/{memberId}") public ResponseEntity findMemberById( @PathVariable Long memberId ) { - return ResponseEntity.ok(memberService.findMemberById(memberId)); + return ResponseEntity.status(HttpStatus.OK) + .body(MemberGetResponse.of(memberService.findById(memberId))); } @DeleteMapping("/{memberId}") diff --git a/sixthAssignment/src/main/java/org/sopt/service/MemberService.java b/sixthAssignment/src/main/java/org/sopt/service/MemberService.java index 52fe568..d98717b 100644 --- a/sixthAssignment/src/main/java/org/sopt/service/MemberService.java +++ b/sixthAssignment/src/main/java/org/sopt/service/MemberService.java @@ -1,12 +1,14 @@ package org.sopt.service; import lombok.RequiredArgsConstructor; -import org.sopt.common.auth.UserAuthentication; +import org.sopt.common.auth.dto.AuthTokenSet; +import org.sopt.common.auth.redis.service.TokenService; import org.sopt.common.dto.request.MemberJoinRequest; import org.sopt.common.dto.response.MemberJoinResponse; import org.sopt.common.auth.jwt.JwtTokenProvider; +import org.sopt.common.dto.response.MemberTokenRefreshResponse; +import org.sopt.common.exception.UnauthorizedException; import org.sopt.domain.Member; -import org.sopt.common.dto.request.MemberCreateRequest; import org.sopt.common.dto.response.MemberGetAllResponse; import org.sopt.common.dto.response.MemberGetResponse; import org.sopt.common.exception.NotFoundException; @@ -15,46 +17,45 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.security.Principal; import java.util.List; +import static org.sopt.common.auth.dto.AuthTokenSet.Type.ACCESS_TOKEN; +import static org.sopt.common.auth.dto.AuthTokenSet.Type.REFRESH_TOKEN; +import static org.sopt.common.auth.jwt.JwtValidationType.VALID_JWT; + @Service @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; - - @Transactional - public String createMemberWithoutAuth( - MemberCreateRequest request - ) { - Member member = request.toEntity(); - memberRepository.save(member); - - return member.getId().toString(); - } + private final TokenService tokenService; @Transactional public MemberJoinResponse createMember( MemberJoinRequest request ) { Member member = memberRepository.save(request.toEntity()); - String accessToken = getToken(member.getId()); + AuthTokenSet token = getTokenSet(member.getId()); - return MemberJoinResponse.of(accessToken, member.getId().toString()); + return MemberJoinResponse.of(token, member.getId().toString()); } - private String getToken(Long memberId) { - UserAuthentication authentication = UserAuthentication.createUserAuthentication(memberId); - - return jwtTokenProvider.issueAccessToken(authentication); + private AuthTokenSet getTokenSet(Long memberId) { + return AuthTokenSet.of( + jwtTokenProvider.issueToken(memberId, ACCESS_TOKEN), + jwtTokenProvider.issueToken(memberId, REFRESH_TOKEN) + ); } - public MemberGetResponse findMemberById( - Long memberId - ) { - return MemberGetResponse.of(memberRepository.findById(memberId) - .orElseThrow(() -> new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND_BY_ID_EXCEPTION))); + public MemberTokenRefreshResponse refreshAccessToken(String refreshToken) { + if (jwtTokenProvider.validateToken(refreshToken) != VALID_JWT) + throw new UnauthorizedException(ErrorMessage.JWT_UNAUTHORIZED_EXCEPTION); + + Long memberId = jwtTokenProvider.getMemberIdFromToken(refreshToken); + String accessToken = jwtTokenProvider.issueToken(memberId, ACCESS_TOKEN); + return MemberTokenRefreshResponse.of(accessToken); } public Member findById(Long memberId) {