diff --git a/build.gradle b/build.gradle index 63ec671..11ccf5e 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,7 @@ dependencies { annotationProcessor "jakarta.persistence:jakarta.persistence-api" implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } def QDomains = [] diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java index 0ab6378..3df76a5 100644 --- a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -15,6 +15,9 @@ public enum ErrorCode { INVALID_APPLE_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않는 Apple Token입니다."), INVALID_JSON_FORMAT(HttpStatus.BAD_REQUEST, "잘못된 JSON 형식입니다."), + // 403 + UNAUTHORIZED_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "권한없는 Refresh Token입니다."), + // 404 NON_EXISTENT_USER_ID(HttpStatus.NOT_FOUND, "해당 id의 사용자가 존재하지 않습니다."), NON_EXISTENT_EMAIL(HttpStatus.NOT_FOUND, "해당 email의 사용자가 존재하지 않습니다."), diff --git a/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java b/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java index 562b0a4..bc964e1 100644 --- a/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java +++ b/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java @@ -13,6 +13,7 @@ import com.dissonance.itit.domain.entity.UserDetailsImpl; import com.dissonance.itit.dto.response.GeneratedToken; +import com.dissonance.itit.service.RedisService; import com.dissonance.itit.service.UserDetailsServiceImpl; import io.jsonwebtoken.Claims; @@ -36,9 +37,7 @@ public class JwtUtil { private String secretKey; private final UserDetailsServiceImpl userDetailsService; - - // TODO: redis를 이용한 refresh token 재발급 구현 - // private final RedisService redisService; + private final RedisService redisService; @PostConstruct protected void init() { @@ -46,24 +45,21 @@ protected void init() { } public GeneratedToken generateToken(String email, String role) { - String refreshToken = generateRefreshToken(email, role); + String refreshToken = generateRefreshToken(); String accessToken = generateAccessToken(email, role); + redisService.setValuesWithTimeout(email, refreshToken, REFRESH_TOKEN_EXPIRATION_TIME.getValue()); + return GeneratedToken.builder() .accessToken(accessToken) .refreshToken(refreshToken) .build(); } - public String generateRefreshToken(String email, String role) { - // Claim에 이메일, 권한 세팅 - Claims claims = Jwts.claims().setSubject(email); - claims.put("role", role); - + public String generateRefreshToken() { Date now = new Date(); return Jwts.builder() - .setClaims(claims) .setIssuedAt(now) // 발행일자 .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME.getValue())) // 만료 일시 .signWith(SignatureAlgorithm.HS256, secretKey) // HS256 알고리즘과 secretKey로 서명 @@ -86,9 +82,9 @@ public String generateAccessToken(String email, String role) { public boolean verifyToken(String token) { try { - // if (redisService.getValues(token) != null && redisService.getValues(token).equals("logout")) { - // throw new JwtException("Invalid JWT Token - logout"); - // } + if (redisService.getValues(token) != null && redisService.getValues(token).equals("logout")) { + throw new JwtException("Invalid JWT Token - logout"); + } Jws claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token); diff --git a/src/main/java/com/dissonance/itit/config/RedisConfig.java b/src/main/java/com/dissonance/itit/config/RedisConfig.java new file mode 100644 index 0000000..91b2f0d --- /dev/null +++ b/src/main/java/com/dissonance/itit/config/RedisConfig.java @@ -0,0 +1,20 @@ +package com.dissonance.itit.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; + +@Configuration +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + @Value("${spring.data.redis.port}") + private Integer port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } +} diff --git a/src/main/java/com/dissonance/itit/controller/UserController.java b/src/main/java/com/dissonance/itit/controller/UserController.java index 517009a..7fc73d1 100644 --- a/src/main/java/com/dissonance/itit/controller/UserController.java +++ b/src/main/java/com/dissonance/itit/controller/UserController.java @@ -1,12 +1,18 @@ package com.dissonance.itit.controller; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +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; import com.dissonance.itit.common.annotation.CurrentUser; import com.dissonance.itit.common.util.ApiResponse; import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.dto.request.RefreshTokenReq; +import com.dissonance.itit.dto.response.GeneratedToken; import com.dissonance.itit.dto.response.LoginUserInfoRes; import com.dissonance.itit.service.UserService; @@ -26,4 +32,30 @@ public ApiResponse getUserInfo(@CurrentUser User loginUser) { return ApiResponse.success(userInfoRes); } + + @PostMapping("reissue") + @Operation(summary = "토큰 재발급", description = "access token과 refresh token을 재발급합니다.") + public ApiResponse reissue(@RequestHeader("Authorization") String requestAccessToken, + @RequestBody RefreshTokenReq tokenRequest) { + GeneratedToken reissuedToken = userService.accessTokenByRefreshToken(requestAccessToken, + tokenRequest.refreshToken()); + + return ApiResponse.success(reissuedToken); + } + + @GetMapping("/logout") + @Operation(summary = "로그아웃", description = "access token을 만료시킵니다.") + public ApiResponse logout(@RequestHeader("Authorization") String requestAccessToken) { + userService.logout(requestAccessToken); + + return ApiResponse.success("로그아웃되었습니다."); + } + + @DeleteMapping + @Operation(summary = "회원 탈퇴", description = "로그인 유저의 계정을 탈퇴시킵니다.") + public ApiResponse withdraw(@CurrentUser User loginUser) { + userService.withdraw(loginUser.getId()); + + return ApiResponse.success("회원 탈퇴가 완료되었습니다."); + } } diff --git a/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java b/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java index 97884d3..41613a2 100644 --- a/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java +++ b/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java @@ -6,7 +6,7 @@ @Getter @AllArgsConstructor public enum JwtTokenExpiration { - ACCESS_TOKEN_EXPIRED_TIME("1시간", 1000L * 60 * 60 * 100000), // TODO: RT 토큰 도입 후 만료 시간 적용 + ACCESS_TOKEN_EXPIRED_TIME("1시간", 1000L * 60 * 60), REFRESH_TOKEN_EXPIRATION_TIME("2주", 1000L * 60 * 60 * 24 * 14); private final String description; diff --git a/src/main/java/com/dissonance/itit/dto/request/RefreshTokenReq.java b/src/main/java/com/dissonance/itit/dto/request/RefreshTokenReq.java new file mode 100644 index 0000000..453a061 --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/request/RefreshTokenReq.java @@ -0,0 +1,6 @@ +package com.dissonance.itit.dto.request; + +public record RefreshTokenReq( + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/service/RedisService.java b/src/main/java/com/dissonance/itit/service/RedisService.java new file mode 100644 index 0000000..f92ba7a --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/RedisService.java @@ -0,0 +1,26 @@ +package com.dissonance.itit.service; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class RedisService { + private final RedisTemplate redisTemplate; + + public String getValues(String key) { + return redisTemplate.opsForValue().get(key); + } + + public void setValuesWithTimeout(String key, String value, long duration) { + redisTemplate.opsForValue().set(key, value, duration, TimeUnit.MILLISECONDS); + } + + public void deleteValues(String key) { + redisTemplate.delete(key); + } +} diff --git a/src/main/java/com/dissonance/itit/service/UserService.java b/src/main/java/com/dissonance/itit/service/UserService.java index 97467d3..a658ad3 100644 --- a/src/main/java/com/dissonance/itit/service/UserService.java +++ b/src/main/java/com/dissonance/itit/service/UserService.java @@ -23,6 +23,7 @@ @Service public class UserService { private final JwtUtil jwtUtil; + private final RedisService redisService; private final UserRepository userRepository; private final OAuthServiceFactory oAuthServiceFactory; @@ -79,4 +80,37 @@ public LoginUserInfoRes getUserInfo(User loginUser) { private boolean isAdmin(User loginUser) { return loginUser.getRole().equals(Role.ADMIN); } + + public GeneratedToken accessTokenByRefreshToken(String accessTokenHeader, String refreshToken) { + String accessToken = jwtUtil.resolveToken(accessTokenHeader); + + String uid = jwtUtil.getUid(accessToken); + String data = redisService.getValues(uid); + + if (data == null || !data.equals(refreshToken)) { + log.info("Invalid Token"); + throw new CustomException(ErrorCode.UNAUTHORIZED_REFRESH_TOKEN); + } + String role = jwtUtil.getRole(accessToken); + + return jwtUtil.generateToken(uid, role); + } + + @Transactional + public void logout(String accessTokenHeader) { + String accessToken = jwtUtil.resolveToken(accessTokenHeader); + + jwtUtil.verifyToken(accessToken); + + String uid = jwtUtil.getUid(accessToken); + long time = jwtUtil.getExpiration(accessToken); + + redisService.setValuesWithTimeout(accessToken, "logout", time); + redisService.deleteValues(uid); + } + + @Transactional + public void withdraw(Long loginUserId) { + userRepository.deleteById(loginUserId); + } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9661b71..4133967 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,6 +30,10 @@ spring: multipart: max-request-size: 10MB max-file-size: 10MB + data: + redis: + port: ${REDIS_PORT} + host: ${REDIS_HOST} jwt: token: