Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,11 @@ public ResponseEntity<SignInResponse> signUp(
public ResponseEntity<Void> signOut(
Authentication authentication
) {
String token = authentication.getCredentials().toString();
if (token == null) {
String accessToken = (String) authentication.getCredentials();
if (accessToken == null || accessToken.isBlank()) {
throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "토큰이 없습니다.");
}
authService.signOut(token);
authService.signOut(accessToken);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 로그아웃시에 refreshToken은 무효화가 안되고 있는 것으로 보았는데 맞을까요?

Copy link
Collaborator Author

@nayonsoso nayonsoso May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그아웃할 때 리프레시 토큰 무효화 해야겠네요…. 이걸 놓치다니😞
새로 이슈 만들었습니다 (#298)
탈퇴 시 토큰을 무효화해야하는 이슈 (#99) 과 함께 다음 PR에서 해보겠습니다!

return ResponseEntity.ok().build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package com.example.solidconnection.auth.dto;

import com.example.solidconnection.auth.service.AccessToken;

public record ReissueResponse(
String accessToken) {
String accessToken
) {

public static ReissueResponse from(AccessToken accessToken) {
return new ReissueResponse(accessToken.token());
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.example.solidconnection.auth.dto;

import com.example.solidconnection.auth.service.AccessToken;
import com.example.solidconnection.auth.service.RefreshToken;

public record SignInResponse(
String accessToken,
String refreshToken
) {

public static SignInResponse of(AccessToken accessToken, RefreshToken refreshToken) {
return new SignInResponse(accessToken.token(), refreshToken.token());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.auth.service;

public record AccessToken(
Subject subject,
String token
) {

public AccessToken(String subject, String token) {
this(new Subject(subject), token);
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,30 @@
package com.example.solidconnection.auth.service;


import com.example.solidconnection.auth.dto.ReissueRequest;
import com.example.solidconnection.auth.dto.ReissueResponse;
import com.example.solidconnection.config.security.JwtProperties;
import com.example.solidconnection.custom.exception.CustomException;
import com.example.solidconnection.siteuser.domain.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.Objects;
import java.util.Optional;

import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED;
import static com.example.solidconnection.util.JwtUtils.parseSubject;

@RequiredArgsConstructor
@Service
public class AuthService {

private final AuthTokenProvider authTokenProvider;
private final JwtProperties jwtProperties;

/*
* 로그아웃 한다.
* - 엑세스 토큰을 블랙리스트에 추가한다.
* */
public void signOut(String accessToken) {
authTokenProvider.generateAndSaveBlackListToken(accessToken);
public void signOut(String token) {
AccessToken accessToken = authTokenProvider.toAccessToken(token);
authTokenProvider.addToBlacklist(accessToken);
}

/*
Expand All @@ -45,19 +40,18 @@ public void quit(SiteUser siteUser) {

/*
* 액세스 토큰을 재발급한다.
* - 요청된 리프레시 토큰과 동일한 subject 의 토큰이 저장되어 있으며 값이 일치할 경우, 액세스 토큰을 재발급한다.
* - 그렇지 않으면 예외를 반환한다.
* - 유효한 리프레시토큰이면, 액세스 토큰을 재발급한다.
* - 그렇지 않으면 예외를 발생시킨다.
* */
public ReissueResponse reissue(ReissueRequest reissueRequest) {
// 리프레시 토큰 확인
String requestedRefreshToken = reissueRequest.refreshToken();
Comment on lines 45 to 48
Copy link
Contributor

@Gyuhyeok99 Gyuhyeok99 May 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토큰 객체를 만들었는데 결국 바디로 String refreshToken을 받아서 사용하고 있는게 쪼끔 일관성이 떨어지는 거 같다는 생각이 들었는데 나중 pr에서 클라이언트는 그대로
{
"refreshToken": "refresh-token-string"
}
로 보내고 ArgumentResolver를 활용해서 서버에선 RefreshToken refreshToken으로 받도록 하는건 어떤가요?

작성해놓고보니 좀 비효율적인 거 같다는 생각도 드네요 이건 😅

String subject = parseSubject(requestedRefreshToken, jwtProperties.secret());
Optional<String> savedRefreshToken = authTokenProvider.findRefreshToken(subject);
if (!Objects.equals(requestedRefreshToken, savedRefreshToken.orElse(null))) {
if (!authTokenProvider.isValidRefreshToken(requestedRefreshToken)) {
throw new CustomException(REFRESH_TOKEN_EXPIRED);
}
// 액세스 토큰 재발급
String newAccessToken = authTokenProvider.generateAccessToken(subject);
return new ReissueResponse(newAccessToken);
Subject subject = authTokenProvider.parseSubject(requestedRefreshToken);
AccessToken newAccessToken = authTokenProvider.generateAccessToken(subject);
return ReissueResponse.from(newAccessToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,68 @@
import com.example.solidconnection.auth.domain.TokenType;
import com.example.solidconnection.config.security.JwtProperties;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.util.JwtUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Optional;
import java.util.Objects;

@Component
public class AuthTokenProvider extends TokenProvider {
public class AuthTokenProvider extends TokenProvider implements BlacklistChecker {

public AuthTokenProvider(JwtProperties jwtProperties, RedisTemplate<String, String> redisTemplate) {
super(jwtProperties, redisTemplate);
}

public String generateAccessToken(SiteUser siteUser) {
String subject = getSubject(siteUser);
return generateToken(subject, TokenType.ACCESS);
public AccessToken generateAccessToken(Subject subject) {
String token = generateToken(subject.value(), TokenType.ACCESS);
return new AccessToken(subject, token);
}

public String generateAccessToken(String subject) {
return generateToken(subject, TokenType.ACCESS);
public RefreshToken generateAndSaveRefreshToken(Subject subject) {
String token = generateToken(subject.value(), TokenType.REFRESH);
saveToken(token, TokenType.REFRESH);
return new RefreshToken(subject, token);
}

public String generateAndSaveRefreshToken(SiteUser siteUser) {
String subject = getSubject(siteUser);
String refreshToken = generateToken(subject, TokenType.REFRESH);
return saveToken(refreshToken, TokenType.REFRESH);
/*
* 액세스 토큰을 블랙리스트에 저장한다.
* - key = BLACKLIST:{accessToken}
* - value = "signOut" -> key 의 존재만 확인하므로, value 에는 무슨 값이 들어가도 상관없다.
* */
public void addToBlacklist(AccessToken accessToken) {
String blackListKey = TokenType.BLACKLIST.addPrefix(accessToken.token());
redisTemplate.opsForValue().set(blackListKey, "signOut");
Comment on lines +35 to +37
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wibaek

놓친부분 짚어주셔서 감사합니다 🥹
엑세스 토큰자체를 저장하도록 바꿨습니다!

}

public String generateAndSaveBlackListToken(String accessToken) {
String blackListToken = generateToken(accessToken, TokenType.BLACKLIST);
return saveToken(blackListToken, TokenType.BLACKLIST);
/*
* 유효한 리프레시 토큰인지 확인한다.
* - 요청된 토큰과 같은 subject 의 리프레시 토큰을 조회한다.
* - 조회된 리프레시 토큰과 요청된 토큰이 같은지 비교한다.
* */
public boolean isValidRefreshToken(String requestedRefreshToken) {
String subject = JwtUtils.parseSubject(requestedRefreshToken, jwtProperties.secret());
String refreshTokenKey = TokenType.REFRESH.addPrefix(subject);
String foundRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey);
return Objects.equals(requestedRefreshToken, foundRefreshToken);
}

public Optional<String> findRefreshToken(String subject) {
String refreshTokenKey = TokenType.REFRESH.addPrefix(subject);
return Optional.ofNullable(redisTemplate.opsForValue().get(refreshTokenKey));
@Override
public boolean isTokenBlacklisted(String accessToken) {
String blackListTokenKey = TokenType.BLACKLIST.addPrefix(accessToken);
return redisTemplate.hasKey(blackListTokenKey);
}

public Subject parseSubject(String token) {
String subject = JwtUtils.parseSubject(token, jwtProperties.secret());
return new Subject(subject);
}

public Optional<String> findBlackListToken(String subject) {
String blackListTokenKey = TokenType.BLACKLIST.addPrefix(subject);
return Optional.ofNullable(redisTemplate.opsForValue().get(blackListTokenKey));
public Subject toSubject(SiteUser siteUser) {
return new Subject(siteUser.getId().toString());
}

private String getSubject(SiteUser siteUser) {
return siteUser.getId().toString();
public AccessToken toAccessToken(String token) {
return new AccessToken(parseSubject(token), token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.solidconnection.auth.service;

public interface BlacklistChecker {

boolean isTokenBlacklisted(String token);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.auth.service;

public record RefreshToken(
Subject subject,
String token
) {

RefreshToken(String subject, String token) {
this(new Subject(subject), token);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ public class SignInService {
@Transactional
public SignInResponse signIn(SiteUser siteUser) {
resetQuitedAt(siteUser);
String accessToken = authTokenProvider.generateAccessToken(siteUser);
String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser);
return new SignInResponse(accessToken, refreshToken);
Subject subject = authTokenProvider.toSubject(siteUser);
AccessToken accessToken = authTokenProvider.generateAccessToken(subject);
RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(subject);
return SignInResponse.of(accessToken, refreshToken);
}

private void resetQuitedAt(SiteUser siteUser) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.solidconnection.auth.service;

public record Subject(
String value
) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.example.solidconnection.custom.security.filter;

import com.example.solidconnection.auth.service.AuthTokenProvider;
import com.example.solidconnection.auth.service.BlacklistChecker;
import com.example.solidconnection.custom.exception.CustomException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
Expand All @@ -20,7 +20,7 @@
@RequiredArgsConstructor
public class SignOutCheckFilter extends OncePerRequestFilter {

private final AuthTokenProvider authTokenProvider;
private final BlacklistChecker tokenBlacklistChecker;

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
Expand All @@ -34,6 +34,6 @@ protected void doFilterInternal(@NonNull HttpServletRequest request,
}

private boolean hasSignedOut(String accessToken) {
return authTokenProvider.findBlackListToken(accessToken).isPresent();
return tokenBlacklistChecker.isTokenBlacklisted(accessToken);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
package com.example.solidconnection.auth.service;

import com.example.solidconnection.auth.domain.TokenType;
import com.example.solidconnection.auth.dto.ReissueRequest;
import com.example.solidconnection.auth.dto.ReissueResponse;
import com.example.solidconnection.config.security.JwtProperties;
import com.example.solidconnection.custom.exception.CustomException;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.support.TestContainerSpringBootTest;
import com.example.solidconnection.type.PreparationStatus;
import com.example.solidconnection.type.Role;
import com.example.solidconnection.util.JwtUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand All @@ -35,19 +32,16 @@ class AuthServiceTest {
@Autowired
private SiteUserRepository siteUserRepository;

@Autowired
private JwtProperties jwtProperties;

@Test
void 로그아웃한다() {
// given
String accessToken = "accessToken";
AccessToken accessToken = authTokenProvider.generateAccessToken(new Subject("subject")); // todo: #296

// when
authService.signOut(accessToken);
authService.signOut(accessToken.token());

// then
assertThat(authTokenProvider.findBlackListToken(accessToken)).isNotNull();
assertThat(authTokenProvider.isTokenBlacklisted(accessToken.token())).isTrue();
}

@Test
Expand All @@ -67,26 +61,25 @@ class AuthServiceTest {
class 토큰을_재발급한다 {

@Test
void 요청의_리프레시_토큰이_저장되어_있고_값이_일치면_액세스_토큰을_재발급한다() {
void 요청의_리프레시_토큰이_저장되어_있으면_액세스_토큰을_재발급한다() {
// given
SiteUser siteUser = createSiteUser();
String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser);
ReissueRequest reissueRequest = new ReissueRequest(refreshToken);
RefreshToken refreshToken = authTokenProvider.generateAndSaveRefreshToken(new Subject("subject"));
ReissueRequest reissueRequest = new ReissueRequest(refreshToken.token());

// when
ReissueResponse reissuedAccessToken = authService.reissue(reissueRequest);

// then
String actualSubject = JwtUtils.parseSubject(reissuedAccessToken.accessToken(), jwtProperties.secret());
String expectedSubject = JwtUtils.parseSubject(refreshToken, jwtProperties.secret());
// then - 요청의 리프레시 토큰과 재발급한 액세스 토큰의 subject 가 동일해야 한다.
Subject expectedSubject = authTokenProvider.parseSubject(refreshToken.token());
Subject actualSubject = authTokenProvider.parseSubject(reissuedAccessToken.accessToken());
assertThat(actualSubject).isEqualTo(expectedSubject);
}

@Test
void 요청의_리프레시_토큰이_저장되어있지_않다면_예외_응답을_반환한다() {
// given
String refreshToken = authTokenProvider.generateToken("subject", TokenType.REFRESH);
ReissueRequest reissueRequest = new ReissueRequest(refreshToken);
String invalidRefreshToken = authTokenProvider.generateAccessToken(new Subject("subject")).token();
ReissueRequest reissueRequest = new ReissueRequest(invalidRefreshToken);

// when, then
assertThatCode(() -> authService.reissue(reissueRequest))
Expand Down
Loading
Loading