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

로그아웃 구현 및 리프레시 토큰 redis에 저장 및 삭제 기능 구현 #68

Merged
merged 1 commit into from
May 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 16 additions & 1 deletion be/issue-tracker/src/main/java/com/issuetracker/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package com.issuetracker;

import com.issuetracker.member.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
@Profile("default") // 테스트시 인터셉터 적용 문제로 프로필 적용
public class WebConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
Expand All @@ -19,6 +26,14 @@ public void addCorsMappings(CorsRegistry registry) {
@Override
public void addInterceptors(InterceptorRegistry registry) {
//프론트 기능구현 후 주석 제거 예정
// registry.addInterceptor(new JwtInterceptor(new JwtUtil())).addPathPatterns("/api/issues", "/api/**");
// registry.addInterceptor(new JwtInterceptor(jwtUtil()))
// .addPathPatterns("/api/**")
// .excludePathPatterns("/api/login", "/api/logout", "/api/refresh",
// "/api/members"); //로그아웃 및 리프레시는 액세스 토큰이 아닌 리프레시 토큰을 헤더에 담으므로 제외
}

@Bean
public JwtUtil jwtUtil() {
return new JwtUtil();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/login")
@RequestMapping("/api")
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;


@PostMapping
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginTryDto loginTryDto) {
LoginResponse loginResponse = loginService.login(loginTryDto);
return ResponseEntity.ok().body(loginResponse);
Expand All @@ -31,4 +31,10 @@ public ResponseEntity<TokenResponse> refreshAccessToken(@RequestHeader String au
TokenResponse tokenResponse = loginService.refreshAccessToken(authorization);
return ResponseEntity.ok().body(tokenResponse);
}

@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestHeader String authorization) {
loginService.logout(authorization);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.issuetracker.member.repository.MemberRepository;
import com.issuetracker.member.util.JwtUtil;
import com.issuetracker.member.util.MemberMapper;
import com.issuetracker.member.util.TokenStoreManager;
import io.jsonwebtoken.Claims;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
Expand All @@ -25,9 +26,10 @@ public class LoginService {
private final MemberRepository memberRepository;
private final FileService fileService;
private final JwtUtil jwtUtil;
private final TokenStoreManager tokenStoreManager;

/**
* 사용자가 입력한 id, password와 대조하여 일치하면 멤버를 반환하고 아니면 예외를 발생시킵니다.
* 사용자가 입력한 id, password와 대조하여 일치하면 유저정보와 토큰을 반환하고 아니면 예외를 발생시킵니다. 리프레시 토큰을 redis에 저장합니다.
*/
@Transactional(readOnly = true)
public LoginResponse login(LoginTryDto loginTryDto) {
Expand All @@ -42,25 +44,50 @@ public LoginResponse login(LoginTryDto loginTryDto) {

String accessToken = jwtUtil.createAccessToken(idValue);
String refreshToken = jwtUtil.createRefreshToken(idValue);

tokenStoreManager.saveRefreshToken(idValue, refreshToken, JwtUtil.REFRESH_EXPIRATION_TIME);
TokenResponse tokenResponse = new TokenResponse(accessToken, refreshToken);

log.info("멤버가 로그인하였습니다. {}", idValue);
return new LoginResponse(memberProfile, tokenResponse);
}

/**
* 리프레시 토큰의 유효성 검증 및 저장소에 존재하는지 확인 후 액세스 토큰을 재발급한다. db에 접근하지 않으므로 transactional은 사용하지 않는다.
*/
public TokenResponse refreshAccessToken(String authorization) {
String refreshToken = jwtUtil.extractJwtToken(authorization);
if (refreshToken == null) {
throw new UnauthorizedException();
}
Claims claims = jwtUtil.validateRefreshToken(refreshToken);
String memberId = jwtUtil.extractMemberId(claims);
// 이 사이에 redis에 저장된 id와 리프레시토큰이 일치하는지 확인해야함
String memberId = extractMemberId(refreshToken);
String storeRefreshToken = tokenStoreManager.getRefreshToken(memberId);
validateTokenEquals(refreshToken, storeRefreshToken);

String accessToken = jwtUtil.createAccessToken(memberId);

return new TokenResponse(accessToken, refreshToken);
}

/**
* 사용자의 리프레시 토큰을 저장소에서 제거한다.
*/
public void logout(String authorization) {
String refreshToken = jwtUtil.extractJwtToken(authorization);
if (refreshToken == null) {
throw new UnauthorizedException();
}
String memberId = extractMemberId(refreshToken);
String storeRefreshToken = tokenStoreManager.getRefreshToken(memberId);

if (storeRefreshToken != null) { // 리프레시 토큰이 이미 만료되어 저장소에서 제거되었을 경우 해당 로직을 실행하지 않는다.
validateTokenEquals(refreshToken, storeRefreshToken);
tokenStoreManager.deleteRefreshToken(memberId);
}

log.info("멤버가 로그아웃 하였습니다. {}", memberId);
}

private Member findTargetMember(String idValue) {
Optional<Member> optionalMember = memberRepository.findById(idValue);
if (optionalMember.isEmpty()) {
Expand All @@ -81,4 +108,15 @@ private String getImgUrl(Member targetMember) {
}
return fileService.getImgUrlById(targetMember.getFileId());
}

private String extractMemberId(String refreshToken) {
Claims claims = jwtUtil.validateRefreshToken(refreshToken);
return jwtUtil.extractMemberId(claims);
}

private void validateTokenEquals(String refreshToken, String storeRefreshToken) {
if (!refreshToken.equals(storeRefreshToken)) {
throw new UnauthorizedException();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
package com.issuetracker.member.util;

import com.issuetracker.global.exception.UnauthorizedException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class JwtUtil {
private static final int REFRESH_EXPIRATION_TIME = 86400000; // 1일
private static final int ACCESS_EXPIRATION_TIME = 3600000; // 1시간
public static final long REFRESH_EXPIRATION_TIME = 86400000; // 1일
public static final long ACCESS_EXPIRATION_TIME = 3600000; // 1시간
@Value("${spring.jwt.refresh-key}")
private String refreshSecretKey;
@Value("${spring.jwt.access-key}")
private String accessSecretKey;


public String createAccessToken(String memberId) {
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
Expand Down Expand Up @@ -56,11 +57,15 @@ public Claims validateAccessToken(String token) {
}

public Claims validateRefreshToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(refreshSecretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
try {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(refreshSecretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
} catch (MalformedJwtException e) {
throw new UnauthorizedException();
}
}

public String extractMemberId(Claims claims) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.issuetracker.member.util;

import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class TokenStoreManager {
private final RedisTemplate<String, String> redisTemplate;

public void saveRefreshToken(String memberId, String refreshToken, long timeout) {
redisTemplate.opsForValue().set(memberId, refreshToken, timeout, TimeUnit.MILLISECONDS);
}

public String getRefreshToken(String memberId) {
return redisTemplate.opsForValue().get(memberId);
}

public void deleteRefreshToken(String memberId) {
redisTemplate.delete(memberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(LabelController.class)
@ActiveProfiles("test")
class LabelControllerTest {
@Autowired
private MockMvc mockMvc;
Expand Down