Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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 @@ -9,6 +9,7 @@
import com.example.solidconnection.auth.dto.SignUpRequest;
import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest;
import com.example.solidconnection.auth.dto.oauth.OAuthResponse;
import com.example.solidconnection.auth.service.AccessToken;
import com.example.solidconnection.auth.service.AuthService;
import com.example.solidconnection.auth.service.CommonSignUpTokenProvider;
import com.example.solidconnection.auth.service.EmailSignInService;
Expand Down Expand Up @@ -97,11 +98,10 @@ public ResponseEntity<SignInResponse> signUp(
public ResponseEntity<Void> signOut(
Authentication authentication
) {
String token = authentication.getCredentials().toString();
if (token == null) {
if (!(authentication.getCredentials() instanceof AccessToken accessToken)) { // null or AccessToken 로 형변환 실패
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

authentication.getCredentials() 여기에는 String type의 엑세스 토큰이 들어있지 않나요?
지금 방식이면 모든 토큰이 여기서 걸려서 예외가 터질 거 같아서요!
한 번 로그 찍어봤는데
Credentials Type: java.lang.String
Is String: true
Is AccessToken: false
이러고 바로 커스텀 예외 터지네요!

Copy link
Copy Markdown
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.

간단하게 그냥

Object credentials = authentication.getCredentials();
System.out.println("Credentials Type: " + (credentials != null ? credentials.getClass().getName() : "null"));
System.out.println("Is String: " + (credentials instanceof String));
System.out.println("Is AccessToken: " + (credentials instanceof AccessToken));
if (!(authentication.getCredentials() instanceof AccessToken accessToken)) { // null or AccessToken 로 형변환 실패
            throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "토큰이 없습니다.");
}

이렇게 찍어봤었어요

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

제가 커밋은 했었는데 푸쉬를 안했었네요😭
꼼꼼한 확인 감사합니다!

Copy link
Copy Markdown
Collaborator Author

@nayonsoso nayonsoso May 7, 2025

Choose a reason for hiding this comment

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

이 코드에 대해 추가 설명드리자면,
이 코드는 제가 “시큐리티에서 사용되는 JwtAuthentication 자체가 AccessToken 을 가지고 있게 하면 어떻게 될까?”
를 생각하며 작성했던 코드입니다. 😅

그런데 그렇게 하지 않은 이유는,
filter 레이어가 auth의 도메인격인 AccessToken을 직접 참조하는 것은 좋은 구조가 아니라고 판단했기 때문입니다.
(물론 지금도 JwtAuthentication가 SiteUser를 직접 참조하고있긴 한데, 짜피 이건 리팩러팅 대상이니깐요🤫 #299)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

이해했습니다!

throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "토큰이 없습니다.");
}
authService.signOut(token);
authService.signOut(accessToken);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
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,29 @@
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(AccessToken accessToken) {
authTokenProvider.addToBlacklist(accessToken);
}

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

/*
* 액세스 토큰을 재발급한다.
* - 요청된 리프레시 토큰과 동일한 subject 의 토큰이 저장되어 있으며 값이 일치할 경우, 액세스 토큰을 재발급한다.
* - 그렇지 않으면 예외를 반환한다.
* - 유효한 리프레시토큰이면, 액세스 토큰을 재발급한다.
* - 그렇지 않으면 예외를 발생시킨다.
* */
public ReissueResponse reissue(ReissueRequest reissueRequest) {
// 리프레시 토큰 확인
String requestedRefreshToken = reissueRequest.refreshToken();
Comment on lines 45 to 48
Copy link
Copy Markdown
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,70 @@
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:{subject}
* - value = {accessToken}
* */
public void addToBlacklist(AccessToken accessToken) {
saveToken(accessToken.token(), TokenType.BLACKLIST);
}

public String generateAndSaveBlackListToken(String accessToken) {
String blackListToken = generateToken(accessToken, TokenType.BLACKLIST);
return saveToken(blackListToken, TokenType.BLACKLIST);
}

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

public Optional<String> findBlackListToken(String subject) {
/*
* 블랙리스트에 등록된 토큰인지 확인한다.
* - 액세스 토큰의 subject 에 해당하는 블랙리스트 토큰을 조회한다.
* - 조회된 블랙리스트 토큰과 요청된 액세스 토큰이 같은지 비교한다.
*/
@Override
public boolean isTokenBlacklisted(String accessToken) {
String subject = JwtUtils.parseSubject(accessToken, jwtProperties.secret());
String blackListTokenKey = TokenType.BLACKLIST.addPrefix(subject);
return Optional.ofNullable(redisTemplate.opsForValue().get(blackListTokenKey));
String foundBlackListToken = redisTemplate.opsForValue().get(blackListTokenKey);
return Objects.equals(accessToken, foundBlackListToken);
}

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

private String getSubject(SiteUser siteUser) {
return siteUser.getId().toString();
public Subject toSubject(SiteUser siteUser) {
return new Subject(siteUser.getId().toString());
}
}
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);

// 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