Skip to content

Commit

Permalink
feat: 로그아웃 구현 및 리프레시 토큰 ㄱedis에 저장 및 삭제 기능 구현
Browse files Browse the repository at this point in the history
- 로그인시 리프레시 토큰을 redis에 저장하고, 로그아웃 요청시 redis에 저장된 refresh 토큰을 삭제
  • Loading branch information
parksangchu committed May 25, 2024
1 parent 72c3258 commit d9b34df
Show file tree
Hide file tree
Showing 6 changed files with 106 additions and 16 deletions.
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

0 comments on commit d9b34df

Please sign in to comment.